{"type":"system","subtype":"init","cwd":"/home/agent/AgentSDE/agent-core","session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","Glob","Grep","ListMcpResourcesTool","Monitor","NotebookEdit","PushNotification","Read","ReadMcpResourceTool","RemoteTrigger","ScheduleWakeup","Skill","TaskOutput","TaskStop","TodoWrite","ToolSearch","WebFetch","WebSearch","Write","mcp__claude_ai_Atlassian__addCommentToJiraIssue","mcp__claude_ai_Atlassian__addWorklogToJiraIssue","mcp__claude_ai_Atlassian__atlassianUserInfo","mcp__claude_ai_Atlassian__createConfluenceFooterComment","mcp__claude_ai_Atlassian__createConfluenceInlineComment","mcp__claude_ai_Atlassian__createConfluencePage","mcp__claude_ai_Atlassian__createIssueLink","mcp__claude_ai_Atlassian__createJiraIssue","mcp__claude_ai_Atlassian__editJiraIssue","mcp__claude_ai_Atlassian__fetch","mcp__claude_ai_Atlassian__getAccessibleAtlassianResources","mcp__claude_ai_Atlassian__getConfluenceCommentChildren","mcp__claude_ai_Atlassian__getConfluencePage","mcp__claude_ai_Atlassian__getConfluencePageDescendants","mcp__claude_ai_Atlassian__getConfluencePageFooterComments","mcp__claude_ai_Atlassian__getConfluencePageInlineComments","mcp__claude_ai_Atlassian__getConfluenceSpaces","mcp__claude_ai_Atlassian__getIssueLinkTypes","mcp__claude_ai_Atlassian__getJiraIssue","mcp__claude_ai_Atlassian__getJiraIssueRemoteIssueLinks","mcp__claude_ai_Atlassian__getJiraIssueTypeMetaWithFields","mcp__claude_ai_Atlassian__getJiraProjectIssueTypesMetadata","mcp__claude_ai_Atlassian__getPagesInConfluenceSpace","mcp__claude_ai_Atlassian__getTransitionsForJiraIssue","mcp__claude_ai_Atlassian__getVisibleJiraProjects","mcp__claude_ai_Atlassian__lookupJiraAccountId","mcp__claude_ai_Atlassian__search","mcp__claude_ai_Atlassian__searchConfluenceUsingCql","mcp__claude_ai_Atlassian__searchJiraIssuesUsingJql","mcp__claude_ai_Atlassian__transitionJiraIssue","mcp__claude_ai_Atlassian__updateConfluencePage","mcp__claude_ai_Gmail__authenticate","mcp__claude_ai_Gmail__complete_authentication","mcp__claude_ai_Google_Calendar__authenticate","mcp__claude_ai_Google_Calendar__complete_authentication","mcp__claude_ai_Google_Drive__authenticate","mcp__claude_ai_Google_Drive__complete_authentication","mcp__claude_ai_Slack__slack_create_canvas","mcp__claude_ai_Slack__slack_read_canvas","mcp__claude_ai_Slack__slack_read_channel","mcp__claude_ai_Slack__slack_read_thread","mcp__claude_ai_Slack__slack_read_user_profile","mcp__claude_ai_Slack__slack_schedule_message","mcp__claude_ai_Slack__slack_search_channels","mcp__claude_ai_Slack__slack_search_public","mcp__claude_ai_Slack__slack_search_public_and_private","mcp__claude_ai_Slack__slack_search_users","mcp__claude_ai_Slack__slack_send_message","mcp__claude_ai_Slack__slack_send_message_draft","mcp__claude_ai_Slack__slack_update_canvas"],"mcp_servers":[{"name":"claude.ai Google Drive","status":"needs-auth"},{"name":"claude.ai Slack","status":"connected"},{"name":"claude.ai Google Calendar","status":"needs-auth"},{"name":"claude.ai Gmail","status":"needs-auth"},{"name":"claude.ai Atlassian","status":"connected"}],"model":"claude-sonnet-4-6","permissionMode":"default","slash_commands":["update-config","debug","simplify","batch","fewer-permission-prompts","loop","schedule","claude-api","ar-pm-sweep","e2e-audit","ar-tech-sweep","ar-e2e-cleanup","ar-ops-sweep","evaluate-issues","plan-task","summarize-document","address-review","compound-learnings","scan-codebase","review-code","implement-code","investigate-and-fix","reply-to-issue-comment","review-document","write-tests","plan-and-propose","deliver-implementation","review-pr","reply-to-pr-comment","product-manager","openclaw","clear","compact","context","heapdump","init","review","security-review","extra-usage","usage","insights","team-onboarding"],"apiKeySource":"none","claude_code_version":"2.1.118","output_style":"default","agents":["Explore","general-purpose","Plan","statusline-setup"],"skills":["update-config","debug","simplify","batch","fewer-permission-prompts","loop","schedule","claude-api","ar-pm-sweep","e2e-audit","ar-tech-sweep","ar-e2e-cleanup","ar-ops-sweep","evaluate-issues","plan-task","summarize-document","address-review","compound-learnings","scan-codebase","review-code","implement-code","investigate-and-fix","reply-to-issue-comment","review-document","write-tests","plan-and-propose","deliver-implementation","review-pr","reply-to-pr-comment","product-manager"],"plugins":[],"uuid":"de02d34f-abb6-4104-be28-3bf7c302f458","memory_paths":{"auto":"/home/agent/.claude/projects/-home-agent-AgentSDE-agent-core/memory/"},"fast_mode_state":"off"}
{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1777055400,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false},"uuid":"219d17df-1085-4f50-851b-55fb1a08a19f","session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01WUHaCnDJaBSabBoqhruiYm","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Let me start by gathering context from the task directory and project root to understand what was done for issue #640.","signature":"ErQCClsIDRgCKkAOBzn5qWjz1teaqgpSzsP4xUqvQvF2Gtnbjbiiw5cRXZgmQfGVHuODesW6dZslRLCk5v3FOjuqkhBZeA4wlyTYMhFjbGF1ZGUtc29ubmV0LTQtNjgAEgwYjk3Uj30NESHN4XYaDCeRwOKYOLGEJmjCqSIwnroS9CmD5OdrzwDyladtVtOHZySKEaKEIjXyPpAiqIwCwC5yuaIRqhRveWuRBvqdKoYB59TV39XAkKPcjGhNajFF1jnaUEyuj5ktaElddMojxelwNSJENjxo5ILeI/z3tYYUdd3dRYCfU9cnntCvIpfBAYPIfIRVVEVzEn7tGv00SQ7ZFREkSPMHDnby6nTW5i5QZis+G1hsUJJ8vZ3QnoGlHCciN1voLuVFtLM+/T47X4Lr8yteR6AYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":11741,"cache_read_input_tokens":11597,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":11741},"output_tokens":6,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"87432b8c-0b3c-4303-a1b1-dbde2efbbf6b"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01WUHaCnDJaBSabBoqhruiYm","type":"message","role":"assistant","content":[{"type":"text","text":"I'll gather context from the task directory and project root to identify reusable learnings from issue #640."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":11741,"cache_read_input_tokens":11597,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":11741},"output_tokens":6,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"72cd73f1-0edf-413c-8866-f89d4a051a4c"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01WUHaCnDJaBSabBoqhruiYm","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VuueXnpUbVnvJXumjN6Yyg","name":"Read","input":{"file_path":"/home/agent/AgentSDE/tasks/production/AgentSDE/agent-core/gh-640/plan/task.json"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":11741,"cache_read_input_tokens":11597,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":11741},"output_tokens":6,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"064b5c35-336b-4bde-a69d-93b5b52d9cd7"}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"File does not exist. Note: your current working directory is /home/agent/AgentSDE/agent-core.","is_error":true,"tool_use_id":"toolu_01VuueXnpUbVnvJXumjN6Yyg"}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"1e60b8f2-04b1-4378-b8db-5fb592989139","timestamp":"2026-04-24T17:28:21.236Z","tool_use_result":"Error: File does not exist. Note: your current working directory is /home/agent/AgentSDE/agent-core."}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01WUHaCnDJaBSabBoqhruiYm","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019sSQcjpBhFf7mAHpux38Yq","name":"Read","input":{"file_path":"/home/agent/AgentSDE/tasks/production/AgentSDE/agent-core/gh-640/plan/plan.md"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":11741,"cache_read_input_tokens":11597,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":11741},"output_tokens":6,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"732a6d85-297f-426d-ba6b-489f9c7fc407"}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"File does not exist. Note: your current working directory is /home/agent/AgentSDE/agent-core.","is_error":true,"tool_use_id":"toolu_019sSQcjpBhFf7mAHpux38Yq"}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"d8318270-e670-4e96-8b4d-cb96c5e1f66c","timestamp":"2026-04-24T17:28:21.702Z","tool_use_result":"Error: File does not exist. Note: your current working directory is /home/agent/AgentSDE/agent-core."}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01WUHaCnDJaBSabBoqhruiYm","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01P34DYZN1cKYGWoCojj1Wdc","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":11741,"cache_read_input_tokens":11597,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":11741},"output_tokens":6,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"f2845221-ec35-46bc-b39e-3e174c5bba16"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01P34DYZN1cKYGWoCojj1Wdc","type":"tool_result","content":"1\t# Agent Configuration — agent-core\n2\t\n3\t## Purpose\n4\t\n5\tThis is the core orchestration server for the AgentSDE pipeline. It is a production NestJS application. Changes here affect the live webhook handler and all downstream agent runs.\n6\t\n7\t## Skills Available\n8\t\n9\t- `/orchestrate` — used for all issue work (plan + implement + PR)\n10\t- `/review-pr` — used when reviewing PRs on this repo\n11\t\n12\t## Workflow\n13\t\n14\t1. Read the issue carefully — understand the impact on the webhook pipeline\n15\t2. Read the relevant source files before making changes\n16\t3. Run `npm run test` locally to verify unit tests pass\n17\t4. Run `npm run lint` — zero warnings are allowed\n18\t5. Create branch: `feat/<issue-number>-<slug>` or `fix/<issue-number>-<slug>`\n19\t6. Commit with message: `feat(<scope>): <what>` or `fix(<scope>): <what>`\n20\t7. Open PR with `Closes #<issue-number>` in the body\n21\t8. Do not merge your own PR — CI must pass first\n22\t9. When adding a new module, env var, or dev command, update CLAUDE.md in the same PR — `.env.example` is the canonical env vars reference; `src/` modules should all appear in the Key modules list\n23\t10. For documentation-only PRs (no TS/JS modified), steps 3–4 (test and lint) may be skipped — state this explicitly in the pre-commit checks comment\n24\t\n25\t## Task Phases\n26\t\n27\t1. `plan` — read the issue and relevant source files, identify the change\n28\t2. `implement` — make the change, write or update unit tests\n29\t3. `test` — run `npm run test` and `npm run lint`, fix any failures\n30\t4. `review` — open PR, wait for human approval gate\n31\t5. `deploy` — handled by CI on merge to master\n32\t\n33\t## Artefacts\n34\t\n35\tTask state is written to `AgentSDE/agent-core-artefacts` repo, branch `AgentSDE/agent-core/<issue-number>`.\n36\t\n37\t## Directive System\n38\t\n39\t- `ALLOWED_DIRECTIVES` in `directive.dto.ts` is the single source of truth — `DirectiveValue` union type auto-derives from it\n40\t- New directives must be wired in **two places**: `DirectiveService.applyDirective()` (internal API path) and `PhaseRouterService.route()` (GitHub webhook path); missing either creates a silent gap\n41\t- Transient directives (`retry`, `restart`) must **not** overwrite `task.directive` — preserve the task's execution directive (`auto`/`careful`/`quick`)\n42\t- Always post a GitHub feedback comment when a directive is rejected (terminal state, live process, unrecognised) — never drop silently\n43\t- When shipping a new directive or pipeline behavior, add its AGENTS.md documentation in the same PR — deferred documentation accumulates as separate cleanup issues\n44\t- Before closing an open PR on replan/restart, always post a \"superseded\" comment explaining the closure — silent PR closes are flagged by reviewers\n45\t- Each directive that resets task state should have its own dedicated reset method (`resetForReplan`, `resetForRestart`) with distinct semantics — avoid sharing or overloading a single method with conditional branching\n46\t- `DirectiveService.applyDirective()` does not enforce actor-level authorization — that responsibility lives at the GitHub webhook layer. In tests, document this boundary with a `TODO` and a reference issue rather than asserting service-level rejection\n47\t\n48\t### `skip-quality-gate` Directive\n49\t- One-time bypass (`/agentsde skip-quality-gate`, legacy: `/agent skip-quality-gate`) of pre-PR quality gate before deliver phase. Sets `task.qualityGateOverride = true`; consumed atomically at start of next deliver phase (reset to `false` before invocation — cannot carry over). Does not skip code review. Confirmation comment posted on set. **Source:** `directive.service.ts` + `phase-router.service.ts`.\n50\t\n51\t### `refine` Directive\n52\t- Re-runs refine phase on a `gated` task. Rejected with a feedback comment if `task.status` is anything other than `gated` (terminal, active, blocked, paused). **Source:** `directive.service.ts` (`applyDirective`, refine branch).\n53\t\n54\t## Multi-Tenant Consumer Migration (Wave 2+)\n55\t\n56\tWhen migrating any service from `GitHubService` to an abstract provider (e.g., `PlatformProvider`), update three layers atomically: (1) swap `@Inject(PLATFORM_PROVIDER) platform: PlatformProvider` in the constructor (remove `GitHubService` import), (2) replace `GitHubModule` with `PlatformModule` in the module `imports` array, (3) switch the spec mock from `{ provide: GitHubService, useValue: {...} }` to `{ provide: PLATFORM_PROVIDER, useValue: {...} }` and update the retrieved mock type to `jest.Mocked<PlatformProvider>`. Methods typed as optional on the provider interface (e.g., `setProjectItemStatus`) must use `?.()` — this is correct and intentional, not a typing gap.\n57\t\n58\t- PRs that are part of Wave 2+ multi-tenant migration should target `rc/multi-tenant` as the base branch — PRs targeting `master` will be redirected by reviewers. Check whether this branch exists before opening the PR.\n59\t- PRs that are part of the token usage tracking feature series (TU-*) should target `rc/token-usage` as the base branch, not `master` — same pattern as `rc/multi-tenant` for the multi-tenant wave. Token usage types live in `src/llm/llm-provider.interface.ts` (`UsageEntry`, `UsageData`); both `PhaseResult` and `SignalResult` carry `usage?: UsageData`.\n60\t- PRs that are part of the release strategy feature series (RS-*) should target `rc/release-strategy` as the base branch, not `master` — same pattern as `rc/multi-tenant` and `rc/token-usage`; check whether this branch exists before opening the PR.\n61\t- PRs that are part of the GitHub Apps feature series (GA-*) should target `rc/github-apps` as the base branch, not `master` — same pattern as `rc/multi-tenant`, `rc/token-usage`, and `rc/release-strategy`; check whether this branch exists before opening the PR. Same applies to Atlassian integration (BJ-*) → `rc/atlassian-integration`. Same applies to Skills MCP Platform (SM-*) → `rc/skills-mcp-platform`.\n62\t- GitHub App auth: sign RS256 JWTs via Node.js `crypto.createSign('RSA-SHA256')` (no external JWT library); read PEM from disk path (not inline env var); use a promise-based lock so concurrent `getToken()` callers share one refresh promise rather than triggering N parallel API calls; refresh proactively when < 5 min remain on the cached installation token. `GitHubModule` registers a `useFactory` for `PLATFORM_AUTH_PROVIDER` that selects `GitHubAppAuthProvider` vs `GitHubPatAuthProvider` based on `GITHUB_APP_ID` presence, and exports the token — importing `GitHubModule` gives consumers both `GitHubService` and `PLATFORM_AUTH_PROVIDER` without additional setup. Call `await this.authProvider.getToken()` inline per request (not cached at construction) to support token refresh. `getBotUsername()` is synchronous and stable — cache it at construction as `private readonly botUsername` rather than calling per event handler; modules that did not previously import `GitHubModule` must add it to their `imports` to expose `PLATFORM_AUTH_PROVIDER`. When migrating static config vars (e.g., `GITHUB_TOKEN`, `AGENT_USERNAME`) to provider resolution, remove them from `configKeys` in `ClaudeInvocationService` — leftover static entries silently shadow the fresh provider values in the worker env.\n63\t- `PR_BASE_BRANCH` in the Claude invocation env overrides the deliver-phase PR target branch — set by `PhaseRouterService.buildEnv()` via `ReleaseService.findActiveForTask(taskId)`; returns `{ rcBranch, releaseId, baseBranch, key, title, status, directive }` when a task is in an active release, `null` otherwise. `buildEnv()` is async for this reason.\n64\t- When a Wave N dependency module (e.g., `PlatformModule`) is not yet merged, provide a minimal inline provider directly in the consuming module rather than importing the missing module — this unblocks Wave N+1 work without waiting for all dependencies to land; remove the stub once the real module is available.\n65\t- `AtlassianOAuthAuthProvider` follows `GitHubAppAuthProvider`'s 5-min cache + promise concurrency-guard pattern with one critical addition: Atlassian rotates the refresh token on every call — persist via `stateRepo.saveRefreshToken` **before** returning the access token; a crash after refresh but before persistence leaves a consumed token that cannot be retried (permanent re-auth required).\n66\t- BJ-* Atlassian platform modules (`JiraModule`, `BitbucketModule`) each export a platform-specific DI token (`JIRA_AUTH_PROVIDER`, `BITBUCKET_AUTH_PROVIDER`) — NOT the shared `PLATFORM_AUTH_PROVIDER`; root wiring into the generic token is BJ-11. Services that need the unified `PLATFORM_AUTH_PROVIDER` must wait for BJ-11; exception: `InvokeModule` imports platform modules with `@Optional()` and collects non-null providers into `PLATFORM_AUTH_PROVIDERS` (array token) — `ClaudeInvocationService.invoke()` selects by `provider.platform === env['PLATFORM']`, throws for unrecognised platforms, always stamps `env['PLATFORM']` on outgoing env, and does not overwrite caller-supplied values. Env keys per platform: github `{GITHUB_TOKEN, AGENT_USERNAME, GITHUB_OWNER, GITHUB_REPO, PROJECT_ID, PROJECT_NUMBER}`, bitbucket `{BITBUCKET_TOKEN, BITBUCKET_BOT_USERNAME, BITBUCKET_WORKSPACE, BITBUCKET_REPO}`, jira `{JIRA_TOKEN, JIRA_BOT_USERNAME, JIRA_SITE_URL, JIRA_CLOUD_ID, JIRA_PROJECT_KEY}`. Factory providers return `null` when the gateway env var (`*_OAUTH_CLIENT_ID`) is unset — consumers must type-guard before use; app boots cleanly without optional credentials. Define DI tokens in the service file and re-export from the module to prevent circular imports between service and module.\n67\t- Jira REST v3 API conventions: comment/description bodies must be ADF-wrapped (`{ type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] }`); labels mutate via `update: { labels: [{ add: 'lbl' }] }` / `[{ remove: 'lbl' }]` on PUT `/rest/api/3/issue/{key}` — NOT `fields.labels` replacement; `setAssignee` uses a dedicated PUT endpoint `/assignee` with `{ accountId }`.\n68\t- Reuse upstream injection tokens (e.g., `LLM_PROVIDER` from `llm-provider.interface.ts`) rather than defining a local duplicate — token mismatches cause silent DI failures that are hard to diagnose.\n69\t- Claude CLI `result` event has two usage objects with different casing: `modelUsage` (per-model, prefer this) uses camelCase fields (`inputTokens`, `cacheReadInputTokens`, `cacheCreationInputTokens`, `costUSD`); fallback aggregate `usage` uses snake_case (`input_tokens`, `cache_read_input_tokens`). Always prefer `modelUsage` for per-model breakdown.\n70\t- When adding a new field to `SignalResult`, update all four `safeResolve()` call sites in `ClaudeInvocationService` (no-signal, success, error, timeout) — missing any one path causes silent data gaps on that outcome.\n71\t- `TokenUsageService.record(taskId, phase, usage)` is the single write path — called once after each `this.claude.invoke()`, no-ops on `undefined`/empty; query methods (`computeSummary`, `computeByTask`, `computeByPhase`, `computeByProvider`, `computeTimeseries`) follow the `MetricsService` pattern with `sinceExpr` filter, excluded-repos list, and raw SQL aggregation.\n72\t\n73\t## Testing Conventions\n74\t\n75\t- Never hardcode production paths (e.g., `/home/agent/AgentSDE/tasks/...`) in test files — CI machines don't have these directories; use `os.tmpdir()` or a `testTmpDir` fixture instead\n76\t- `ClaudeInvocationService` parses stdout as JSON lines (stream-json format); test stubs must wrap signals in JSON objects, not raw strings\n77\t- When multiple services derive the same filesystem path, centralize it in one helper (e.g., `ArtefactsService.buildCentralTaskDir()`) — prevents path drift across services\n78\t- Prefer real temp dirs over filesystem mocks for I/O-heavy services — mocking `fs` in unit tests for services like `ClaudeInvocationService` raises reviewer concerns and can miss real I/O edge cases\n79\t- When injecting a new shared service into multiple existing modules, update all three layers per consumer: (1) constructor injection in the service, (2) the consumer's module `imports` array (e.g., `TenantModule`), and (3) a mock provider in each `.spec.ts` — reviewers flag any missing layer as a DI breakage risk\n80\t- When pre-existing test failures exist on `master` unrelated to the change, call them out explicitly in the PR body (e.g., \"6 pre-existing watchdog failures unchanged\") — reviewers cannot distinguish regressions from known failures without this\n81\t- When adding a new pipeline status or phase transition, always cover three test scenarios: (1) idempotent re-entry (target state already set), (2) happy path, and (3) recovery path where the triggering condition resolves before the threshold — reviewers flag gaps in these as non-obvious but high-value coverage\n82\t- When testing services that read env vars directly, save and restore original values in `beforeEach`/`afterEach` — unrestored env mutations (e.g., `STUCK_THRESHOLD_MS`, `SLOW_THRESHOLD_MS`) silently corrupt other test suites running in the same Jest worker process\n83\t- For directive integration tests, wire `DirectiveService` with real `TaskStateService` + in-memory SQLite and mocked `GitHubService`/`ArtefactsService` — follow the `blocked-persistent-to-refine` pattern; each new directive spec should cover (1) happy path with open PR, (2) no-open-PR variant, and (3) terminal/active task rejection guard\n84\t- A cancelled E2E CI job (step interrupted by infra timeout, not an assertion failure) is not a code regression — re-trigger E2E after the fix; if the timeout recurs, escalate as a CI infra issue rather than treating it as a test failure\n85\t\n86\t## Logging Conventions\n87\t\n88\t- `{taskDir}/meta/` is the canonical per-task log directory — already included in `ARTEFACT_SUBDIRS`; files written there are synced to `agent-core-artefacts` automatically without further config changes\n89\t- When piping spawned-process stdout/stderr to a file write stream, open in append mode (`flags: 'a'`) and close the stream in both the `close` and `error` event handlers to avoid resource leaks\n90\t- In cleanup methods that `unlink` files in a loop, only swallow `ENOENT` errors; re-throw or log all other errors — broad `catch` blocks in cleanup paths are reviewers' top flag for silent data-loss risk\n91\t\n92\t## Adding a New Pipeline Phase\n93\t\n94\tWhen adding a new SDLC phase, all of the following must be updated in the same PR:\n95\t- `PHASE_ORDER` array and `phaseColumn()` map in `InternalAdapterService`\n96\t- `PHASE_TO_SKILL` map in `ClaudeInvocationService`\n97\t- New `phase<Name>` column on `TaskEntity` (with `DEFAULT 'pending'` for safe migration)\n98\t- `phaseName: 'pending'` in `createTask()` defaults in `TaskStateService`\n99\t- `onPhaseSkip` / `onPhaseComplete` hooks in `PhaseHooksService` if the phase needs lifecycle handling\n100\t- New skill files under `.claude/skills/<phase>/` (SKILL.md + role.md)\n101\t**PR sizing:** Even though all these files change together, consider splitting DB schema changes (migration + entity) into a separate PR from routing and skill logic — reviewers flag large pipeline changes as high-risk.\n102\t- `phaseKey()` static map in `TaskStateService` — do NOT rely on dynamic camelCase derivation; it produces `phaseAddress_review` instead of `phaseAddressReview` for underscore-named phases; add an explicit entry to the static map for every new phase\n103\t- `setPhaseStatus(task, phase, status)` only mutates the in-memory entity — it does not persist to DB; to save the change, pass the field explicitly in the `updateTaskStatus()` call (e.g., `{ phaseReview: 'skipped' }`); entity mutation alone is silently discarded and will not survive the next DB read\n104\t\n105\t## Worktree Management\n106\t\n107\t- Use idempotent `getOrCreateWorktree()` instead of per-phase create/destroy: check if worktree is registered via `git worktree list --porcelain`, attach an existing branch without `-b`, or fresh-create — avoids `git branch -D` failures on checked-out branches that cause permanent stuck loops\n108\t- Move worktree cleanup to terminal state (`complete`) in `InternalAdapterService`, not in `finally` blocks inside `PhaseRouterService` — per-phase cleanup destroys worktrees still needed by subsequent phases\n109\t- Tasks that never reach `complete` (permanently blocked or abandoned) accumulate worktrees indefinitely — any cancellation or watchdog path that abandons a task must also call `cleanupWorktree()`\n110\t- On worktree reuse, run only `git fetch origin` to update remote-tracking refs — never `git reset --hard origin/master`; the branch already holds agent commits from prior phases, and a hard reset silently destroys them\n111\t\n112\t## Gate & Merge\n113\t\n114\t- Use an explicit `GATE_BYPASS_EVENT_TYPES = new Set(['directive', 'review_approved', 'ci_passed', 'ci_failure'])` in `PhaseRouterService` — replacing a bare `event.type !== 'directive'` check prevents `review_approved` and CI events from re-gating an already-approved task\n115\t- When a `review_approved` event arrives and the deliver phase is already complete, call `mergeIfReady()` directly — extract merge logic into a shared public method so both `onDeliverComplete` and the `review_approved` path reuse it without duplication\n116\t- Every phase-completion code path must call `phaseHooks.onPhaseComplete(task, phase)` explicitly — hooks inside `executePhase` do not fire on alternative paths (e.g., `review_approved` clearing the gate); a missing call silently skips lifecycle effects such as auto-merge even when the phase status is correctly written\n117\t\n118\t## Release Strategy\n119\t\n120\tA release is a configuration override that groups tasks and routes their PRs to an RC branch instead of master.\n121\t\n122\t### Data Model\n123\t\n124\t3 tables: `releases`, `release_tasks`, `release_repo_prs`.\n125\t\n126\t### Pipeline Integration\n127\t\n128\tWhen a task belongs to an active release:\n129\t- `PhaseRouterService.buildEnv()` sets `PR_BASE_BRANCH` to the RC branch\n130\t- The deliver skill creates PRs targeting the RC branch (not master)\n131\t- `findActiveForTask(taskId)` is the key lookup — queries `release_tasks` → `releases`\n132\t\n133\t- When a task belongs to an active release, pass `rcBranch` as `baseBranch` to `getOrCreateWorktree()` (default `'master'`) — omitting it creates worktrees from master, not the RC branch; fetch the release config once in `executePhase()` and thread it through to both `getOrCreateWorktree()` and `buildEnv()` to avoid a duplicate `findActiveForTask()` call.\n134\t- `GitHubService.findCommentByPrefix(owner, repo, issueNumber, prefix)` paginates issue comments (`per_page=100`) and returns `{ id, body } | null` — use it to locate a specific comment (e.g. `## Agent Plan`) before editing it.\n135\t- `PhaseHooksService.onPlanComplete()` appends a `## Release Context` section (human-readable + collapsed JSON metadata) to the existing plan comment when the task has an active release; falls back to posting a standalone comment if the plan comment is not found (handles GitHub API propagation lag).\n136\t\n137\t### Status Lifecycle\n138\t\n139\t`draft` → `active` → `staging` → `validating` → `validated` → `shipping` → `shipped`\n140\t(`failed` can transition back to `active` for retry)\n141\t\n142\t## Watchdog\n143\t\n144\t- Query `['active', 'gated']` statuses in `WatchdogService` — tasks waiting indefinitely for deliver-gate approval are invisible with `active`-only queries and accumulate as silent stuck issues\n145\t- Expose `POST /internal/watchdog/run` (returns `{ stuck, slow }`) for on-demand detection — avoids E2E tests waiting 5 minutes for the cron cycle; `runCheck()` should be a public method on the service\n146\t\n147\t## Test Environment\n148\t\n149\tA test instance of agent-core runs alongside production for E2E verification before merging to master.\n150\t\n151\t- **Test clone:** `/home/agent/AgentSDE/testing/agent-core` (branch: `testing`)\n152\t- **Port:** 8777, managed by pm2 as `agent-core-test`\n153\t- **Database:** `agent-core-test.db` (configured via `DB_NAME` env var, separate from production `agent-core.db`)\n154\t- **Nginx:** `wh.kontashera.com/test/*` proxies to `localhost:8777`\n155\t- **Webhook:** Repo-level webhook on `AgentSDE/agent-core-e2e` delivers to `wh.kontashera.com/test/webhooks/github`\n156\t- Never modify the test instance while production E2E or nightly runs are in progress\n157\t- Always restore `agent-core-e2e/.env` `AGENT_CORE_URL` back to `http://localhost:3000` after testing\n158\t- The `testing` branch can be force-pushed freely — it is not protected\n159\t\n160\t## Database Entities\n161\t\n162\tWhen adding a new TypeORM entity, update four locations atomically:\n163\t1. Create entity file under `src/database/entities/<name>.entity.ts`\n164\t2. Export from `src/database/entities/index.ts` barrel\n165\t3. Add to the `forRoot` entities array in `DatabaseModule`\n166\t4. Add to the `forFeature` array in `DatabaseModule`\n167\t\n168\tFor SQLite: use `type: 'integer'`/`type: 'real'` for numeric columns (TypeORM defaults don't map correctly); use `@Column({ name: 'snake_case_name' })` for consistent snake_case DB column naming.\n169\t- When a service needs entities from another domain (e.g., `TaskEntity` in `ReleaseModule`), add them to the local `TypeOrmModule.forFeature()` array — do NOT import the owning module; avoids circular dependency risks.\n170\t- When removing a TypeORM entity, mirror the four-location add pattern in reverse: delete the entity file, remove its export from `entities/index.ts`, remove it from the `forRoot` entities array in `DatabaseModule`, and remove `forFeature` references in all consumer modules (e.g., `OperationalModule`, `ControlApiModule`) and their spec files.\n171\t- Before inserting into a join/relation table, always query existing rows and filter them out — silently inserting duplicates breaks unique constraints and is hard to diagnose at runtime.\n172\t- When an issue specifies Postgres-style schema changes (migration files, `uuid_generate_v4()`, `timestamptz`), translate to a TypeORM entity with SQLite-compatible types (`@PrimaryGeneratedColumn('uuid')`, `datetime`) — no migration tooling exists in this repo; `synchronize: true` is the schema mechanism. Verify the schema in `database.module.spec.ts` with in-memory SQLite covering uuid generation, unique-constraint enforcement, and cross-tenant reuse.\n173\t\n174\t## REST Controllers\n175\t\n176\t- Throw HTTP exceptions (`NotFoundException`, etc.) in the controller layer, not in the service — services should return `null`/`undefined` for \"not found\" cases; the controller converts these to the appropriate HTTP status using NestJS built-in exceptions.\n177\t- Register new controllers in their owning module and import that module into `AppModule` in the same PR — a controller not imported into `AppModule` is silently unreachable.\n178\t\n179\t## Redis / BullMQ\n180\t\n181\t- Use a dedicated `ioredis` instance (`lazyConnect: true`) in `HealthModule` for `/health` Redis probes — never share BullMQ's internal connection; health checks coupled to queue lifecycle report misleading states during degraded scenarios\n182\t- Register queues via `BullModule.forRootAsync()` (ConfigService-driven) + `BullModule.registerQueue()` in `AppModule` — this is the established queue registration pattern\n183\t- Redis pub/sub requires two separate ioredis clients — one dedicated to publish (`pub`), one to subscribe (`sub`); a client in subscribe mode cannot also publish. Follow the `EventBusModule` factory pattern. `emit()` must swallow Redis errors; per-handler error isolation in `on()` prevents one failing handler from blocking others — event delivery must never block pipeline advancement\n184\t- If the BullMQ class manages its own `IORedis` connections directly, do NOT also register `BullModule.forRootAsync` in the same feature module — it conflicts with `InvokeModule`'s existing registration; use direct `IORedis` instances instead.\n185\t- **BullMQ stall-retry idempotency**: Wrap `createTask()` in `route()` with try/catch; on unique-constraint error fall back to `findByIssueAndRepo()` — concurrent deliveries share the existing row. Guard phase hooks (`onPhaseBlocked`, `onCompoundComplete`) with per-phase event records before posting comments; skip if already recorded. Add early-return in `executePhase()` if phase is already `'active'`. Extend phase-event dedup guard to skip when `phaseStatus === 'failed' && task.status === 'gated'` — persistent-block stalls differ from transient retries (which keep `status === 'active'`).\n186\t\n187\t## Config Schema\n188\t\n189\t- Use Joi `.when()` for conditional env var validation in two directions: (a) make existing required fields optional when a new auth mode flag is present (`Joi.string().when('FLAG', { is: Joi.exist(), then: Joi.optional(), otherwise: Joi.required() })`); (b) make companion vars required when an optional provider's gateway var is set (e.g., `*_OAUTH_CLIENT_ID`). Designate one gateway var per provider group — partial configs missing the gateway silently pass (intentional; mirrors `GITHUB_APP_ID` pattern).\n190\t- Config schema tests for conditional validators must cover three cases: (1) empty/legacy config passes with no error, (2) gateway set with all required companions passes, (3) gateway set but companions missing fails and lists the missing vars — case (3) is the partial-fail guard that prevents silent misconfiguration.\n191\t\n192\t## Important constraints\n193\t\n194\t- This service runs live on the VPS via pm2 (`pm2 restart agent-core` to reload)\n195\t- Port `3000` is proxied through nginx — do not change the port without updating nginx config\n196\t- `GITHUB_WEBHOOK_SECRET` must match the secret configured in the GitHub org webhook\n197\t- The WAL file path (`agent-core-artefacts/watchdog/wal.jsonl`) is shared with the watchdog — do not change its location without updating both services\n198\t- `ClaudeInvocationService.invoke()` is now the primary entry point for agent runs; `dispatch-v2.sh`/`DispatchService` is legacy and being superseded by the `invoke` module\n199\t- `GITHUB_WEBHOOK_SECRET` must be read with `get()` (not `getOrThrow()`) and verification skipped when unset — E2E environments deliberately omit the secret; `getOrThrow()` returns HTTP 500 instead of gracefully ignoring the event\n200\t- `TASK_DIR` is resolved by `ArtefactsService.buildCentralTaskDir()` and set in the Claude invocation env by `PhaseRouterService` — do not construct it independently in other services\n201\t"}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"dbaf2359-dbb4-4bbb-937a-e262321b81ca","timestamp":"2026-04-24T17:28:22.664Z","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","content":"# Agent Configuration — agent-core\n\n## Purpose\n\nThis is the core orchestration server for the AgentSDE pipeline. It is a production NestJS application. Changes here affect the live webhook handler and all downstream agent runs.\n\n## Skills Available\n\n- `/orchestrate` — used for all issue work (plan + implement + PR)\n- `/review-pr` — used when reviewing PRs on this repo\n\n## Workflow\n\n1. Read the issue carefully — understand the impact on the webhook pipeline\n2. Read the relevant source files before making changes\n3. Run `npm run test` locally to verify unit tests pass\n4. Run `npm run lint` — zero warnings are allowed\n5. Create branch: `feat/<issue-number>-<slug>` or `fix/<issue-number>-<slug>`\n6. Commit with message: `feat(<scope>): <what>` or `fix(<scope>): <what>`\n7. Open PR with `Closes #<issue-number>` in the body\n8. Do not merge your own PR — CI must pass first\n9. When adding a new module, env var, or dev command, update CLAUDE.md in the same PR — `.env.example` is the canonical env vars reference; `src/` modules should all appear in the Key modules list\n10. For documentation-only PRs (no TS/JS modified), steps 3–4 (test and lint) may be skipped — state this explicitly in the pre-commit checks comment\n\n## Task Phases\n\n1. `plan` — read the issue and relevant source files, identify the change\n2. `implement` — make the change, write or update unit tests\n3. `test` — run `npm run test` and `npm run lint`, fix any failures\n4. `review` — open PR, wait for human approval gate\n5. `deploy` — handled by CI on merge to master\n\n## Artefacts\n\nTask state is written to `AgentSDE/agent-core-artefacts` repo, branch `AgentSDE/agent-core/<issue-number>`.\n\n## Directive System\n\n- `ALLOWED_DIRECTIVES` in `directive.dto.ts` is the single source of truth — `DirectiveValue` union type auto-derives from it\n- New directives must be wired in **two places**: `DirectiveService.applyDirective()` (internal API path) and `PhaseRouterService.route()` (GitHub webhook path); missing either creates a silent gap\n- Transient directives (`retry`, `restart`) must **not** overwrite `task.directive` — preserve the task's execution directive (`auto`/`careful`/`quick`)\n- Always post a GitHub feedback comment when a directive is rejected (terminal state, live process, unrecognised) — never drop silently\n- When shipping a new directive or pipeline behavior, add its AGENTS.md documentation in the same PR — deferred documentation accumulates as separate cleanup issues\n- Before closing an open PR on replan/restart, always post a \"superseded\" comment explaining the closure — silent PR closes are flagged by reviewers\n- Each directive that resets task state should have its own dedicated reset method (`resetForReplan`, `resetForRestart`) with distinct semantics — avoid sharing or overloading a single method with conditional branching\n- `DirectiveService.applyDirective()` does not enforce actor-level authorization — that responsibility lives at the GitHub webhook layer. In tests, document this boundary with a `TODO` and a reference issue rather than asserting service-level rejection\n\n### `skip-quality-gate` Directive\n- One-time bypass (`/agentsde skip-quality-gate`, legacy: `/agent skip-quality-gate`) of pre-PR quality gate before deliver phase. Sets `task.qualityGateOverride = true`; consumed atomically at start of next deliver phase (reset to `false` before invocation — cannot carry over). Does not skip code review. Confirmation comment posted on set. **Source:** `directive.service.ts` + `phase-router.service.ts`.\n\n### `refine` Directive\n- Re-runs refine phase on a `gated` task. Rejected with a feedback comment if `task.status` is anything other than `gated` (terminal, active, blocked, paused). **Source:** `directive.service.ts` (`applyDirective`, refine branch).\n\n## Multi-Tenant Consumer Migration (Wave 2+)\n\nWhen migrating any service from `GitHubService` to an abstract provider (e.g., `PlatformProvider`), update three layers atomically: (1) swap `@Inject(PLATFORM_PROVIDER) platform: PlatformProvider` in the constructor (remove `GitHubService` import), (2) replace `GitHubModule` with `PlatformModule` in the module `imports` array, (3) switch the spec mock from `{ provide: GitHubService, useValue: {...} }` to `{ provide: PLATFORM_PROVIDER, useValue: {...} }` and update the retrieved mock type to `jest.Mocked<PlatformProvider>`. Methods typed as optional on the provider interface (e.g., `setProjectItemStatus`) must use `?.()` — this is correct and intentional, not a typing gap.\n\n- PRs that are part of Wave 2+ multi-tenant migration should target `rc/multi-tenant` as the base branch — PRs targeting `master` will be redirected by reviewers. Check whether this branch exists before opening the PR.\n- PRs that are part of the token usage tracking feature series (TU-*) should target `rc/token-usage` as the base branch, not `master` — same pattern as `rc/multi-tenant` for the multi-tenant wave. Token usage types live in `src/llm/llm-provider.interface.ts` (`UsageEntry`, `UsageData`); both `PhaseResult` and `SignalResult` carry `usage?: UsageData`.\n- PRs that are part of the release strategy feature series (RS-*) should target `rc/release-strategy` as the base branch, not `master` — same pattern as `rc/multi-tenant` and `rc/token-usage`; check whether this branch exists before opening the PR.\n- PRs that are part of the GitHub Apps feature series (GA-*) should target `rc/github-apps` as the base branch, not `master` — same pattern as `rc/multi-tenant`, `rc/token-usage`, and `rc/release-strategy`; check whether this branch exists before opening the PR. Same applies to Atlassian integration (BJ-*) → `rc/atlassian-integration`. Same applies to Skills MCP Platform (SM-*) → `rc/skills-mcp-platform`.\n- GitHub App auth: sign RS256 JWTs via Node.js `crypto.createSign('RSA-SHA256')` (no external JWT library); read PEM from disk path (not inline env var); use a promise-based lock so concurrent `getToken()` callers share one refresh promise rather than triggering N parallel API calls; refresh proactively when < 5 min remain on the cached installation token. `GitHubModule` registers a `useFactory` for `PLATFORM_AUTH_PROVIDER` that selects `GitHubAppAuthProvider` vs `GitHubPatAuthProvider` based on `GITHUB_APP_ID` presence, and exports the token — importing `GitHubModule` gives consumers both `GitHubService` and `PLATFORM_AUTH_PROVIDER` without additional setup. Call `await this.authProvider.getToken()` inline per request (not cached at construction) to support token refresh. `getBotUsername()` is synchronous and stable — cache it at construction as `private readonly botUsername` rather than calling per event handler; modules that did not previously import `GitHubModule` must add it to their `imports` to expose `PLATFORM_AUTH_PROVIDER`. When migrating static config vars (e.g., `GITHUB_TOKEN`, `AGENT_USERNAME`) to provider resolution, remove them from `configKeys` in `ClaudeInvocationService` — leftover static entries silently shadow the fresh provider values in the worker env.\n- `PR_BASE_BRANCH` in the Claude invocation env overrides the deliver-phase PR target branch — set by `PhaseRouterService.buildEnv()` via `ReleaseService.findActiveForTask(taskId)`; returns `{ rcBranch, releaseId, baseBranch, key, title, status, directive }` when a task is in an active release, `null` otherwise. `buildEnv()` is async for this reason.\n- When a Wave N dependency module (e.g., `PlatformModule`) is not yet merged, provide a minimal inline provider directly in the consuming module rather than importing the missing module — this unblocks Wave N+1 work without waiting for all dependencies to land; remove the stub once the real module is available.\n- `AtlassianOAuthAuthProvider` follows `GitHubAppAuthProvider`'s 5-min cache + promise concurrency-guard pattern with one critical addition: Atlassian rotates the refresh token on every call — persist via `stateRepo.saveRefreshToken` **before** returning the access token; a crash after refresh but before persistence leaves a consumed token that cannot be retried (permanent re-auth required).\n- BJ-* Atlassian platform modules (`JiraModule`, `BitbucketModule`) each export a platform-specific DI token (`JIRA_AUTH_PROVIDER`, `BITBUCKET_AUTH_PROVIDER`) — NOT the shared `PLATFORM_AUTH_PROVIDER`; root wiring into the generic token is BJ-11. Services that need the unified `PLATFORM_AUTH_PROVIDER` must wait for BJ-11; exception: `InvokeModule` imports platform modules with `@Optional()` and collects non-null providers into `PLATFORM_AUTH_PROVIDERS` (array token) — `ClaudeInvocationService.invoke()` selects by `provider.platform === env['PLATFORM']`, throws for unrecognised platforms, always stamps `env['PLATFORM']` on outgoing env, and does not overwrite caller-supplied values. Env keys per platform: github `{GITHUB_TOKEN, AGENT_USERNAME, GITHUB_OWNER, GITHUB_REPO, PROJECT_ID, PROJECT_NUMBER}`, bitbucket `{BITBUCKET_TOKEN, BITBUCKET_BOT_USERNAME, BITBUCKET_WORKSPACE, BITBUCKET_REPO}`, jira `{JIRA_TOKEN, JIRA_BOT_USERNAME, JIRA_SITE_URL, JIRA_CLOUD_ID, JIRA_PROJECT_KEY}`. Factory providers return `null` when the gateway env var (`*_OAUTH_CLIENT_ID`) is unset — consumers must type-guard before use; app boots cleanly without optional credentials. Define DI tokens in the service file and re-export from the module to prevent circular imports between service and module.\n- Jira REST v3 API conventions: comment/description bodies must be ADF-wrapped (`{ type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] }`); labels mutate via `update: { labels: [{ add: 'lbl' }] }` / `[{ remove: 'lbl' }]` on PUT `/rest/api/3/issue/{key}` — NOT `fields.labels` replacement; `setAssignee` uses a dedicated PUT endpoint `/assignee` with `{ accountId }`.\n- Reuse upstream injection tokens (e.g., `LLM_PROVIDER` from `llm-provider.interface.ts`) rather than defining a local duplicate — token mismatches cause silent DI failures that are hard to diagnose.\n- Claude CLI `result` event has two usage objects with different casing: `modelUsage` (per-model, prefer this) uses camelCase fields (`inputTokens`, `cacheReadInputTokens`, `cacheCreationInputTokens`, `costUSD`); fallback aggregate `usage` uses snake_case (`input_tokens`, `cache_read_input_tokens`). Always prefer `modelUsage` for per-model breakdown.\n- When adding a new field to `SignalResult`, update all four `safeResolve()` call sites in `ClaudeInvocationService` (no-signal, success, error, timeout) — missing any one path causes silent data gaps on that outcome.\n- `TokenUsageService.record(taskId, phase, usage)` is the single write path — called once after each `this.claude.invoke()`, no-ops on `undefined`/empty; query methods (`computeSummary`, `computeByTask`, `computeByPhase`, `computeByProvider`, `computeTimeseries`) follow the `MetricsService` pattern with `sinceExpr` filter, excluded-repos list, and raw SQL aggregation.\n\n## Testing Conventions\n\n- Never hardcode production paths (e.g., `/home/agent/AgentSDE/tasks/...`) in test files — CI machines don't have these directories; use `os.tmpdir()` or a `testTmpDir` fixture instead\n- `ClaudeInvocationService` parses stdout as JSON lines (stream-json format); test stubs must wrap signals in JSON objects, not raw strings\n- When multiple services derive the same filesystem path, centralize it in one helper (e.g., `ArtefactsService.buildCentralTaskDir()`) — prevents path drift across services\n- Prefer real temp dirs over filesystem mocks for I/O-heavy services — mocking `fs` in unit tests for services like `ClaudeInvocationService` raises reviewer concerns and can miss real I/O edge cases\n- When injecting a new shared service into multiple existing modules, update all three layers per consumer: (1) constructor injection in the service, (2) the consumer's module `imports` array (e.g., `TenantModule`), and (3) a mock provider in each `.spec.ts` — reviewers flag any missing layer as a DI breakage risk\n- When pre-existing test failures exist on `master` unrelated to the change, call them out explicitly in the PR body (e.g., \"6 pre-existing watchdog failures unchanged\") — reviewers cannot distinguish regressions from known failures without this\n- When adding a new pipeline status or phase transition, always cover three test scenarios: (1) idempotent re-entry (target state already set), (2) happy path, and (3) recovery path where the triggering condition resolves before the threshold — reviewers flag gaps in these as non-obvious but high-value coverage\n- When testing services that read env vars directly, save and restore original values in `beforeEach`/`afterEach` — unrestored env mutations (e.g., `STUCK_THRESHOLD_MS`, `SLOW_THRESHOLD_MS`) silently corrupt other test suites running in the same Jest worker process\n- For directive integration tests, wire `DirectiveService` with real `TaskStateService` + in-memory SQLite and mocked `GitHubService`/`ArtefactsService` — follow the `blocked-persistent-to-refine` pattern; each new directive spec should cover (1) happy path with open PR, (2) no-open-PR variant, and (3) terminal/active task rejection guard\n- A cancelled E2E CI job (step interrupted by infra timeout, not an assertion failure) is not a code regression — re-trigger E2E after the fix; if the timeout recurs, escalate as a CI infra issue rather than treating it as a test failure\n\n## Logging Conventions\n\n- `{taskDir}/meta/` is the canonical per-task log directory — already included in `ARTEFACT_SUBDIRS`; files written there are synced to `agent-core-artefacts` automatically without further config changes\n- When piping spawned-process stdout/stderr to a file write stream, open in append mode (`flags: 'a'`) and close the stream in both the `close` and `error` event handlers to avoid resource leaks\n- In cleanup methods that `unlink` files in a loop, only swallow `ENOENT` errors; re-throw or log all other errors — broad `catch` blocks in cleanup paths are reviewers' top flag for silent data-loss risk\n\n## Adding a New Pipeline Phase\n\nWhen adding a new SDLC phase, all of the following must be updated in the same PR:\n- `PHASE_ORDER` array and `phaseColumn()` map in `InternalAdapterService`\n- `PHASE_TO_SKILL` map in `ClaudeInvocationService`\n- New `phase<Name>` column on `TaskEntity` (with `DEFAULT 'pending'` for safe migration)\n- `phaseName: 'pending'` in `createTask()` defaults in `TaskStateService`\n- `onPhaseSkip` / `onPhaseComplete` hooks in `PhaseHooksService` if the phase needs lifecycle handling\n- New skill files under `.claude/skills/<phase>/` (SKILL.md + role.md)\n**PR sizing:** Even though all these files change together, consider splitting DB schema changes (migration + entity) into a separate PR from routing and skill logic — reviewers flag large pipeline changes as high-risk.\n- `phaseKey()` static map in `TaskStateService` — do NOT rely on dynamic camelCase derivation; it produces `phaseAddress_review` instead of `phaseAddressReview` for underscore-named phases; add an explicit entry to the static map for every new phase\n- `setPhaseStatus(task, phase, status)` only mutates the in-memory entity — it does not persist to DB; to save the change, pass the field explicitly in the `updateTaskStatus()` call (e.g., `{ phaseReview: 'skipped' }`); entity mutation alone is silently discarded and will not survive the next DB read\n\n## Worktree Management\n\n- Use idempotent `getOrCreateWorktree()` instead of per-phase create/destroy: check if worktree is registered via `git worktree list --porcelain`, attach an existing branch without `-b`, or fresh-create — avoids `git branch -D` failures on checked-out branches that cause permanent stuck loops\n- Move worktree cleanup to terminal state (`complete`) in `InternalAdapterService`, not in `finally` blocks inside `PhaseRouterService` — per-phase cleanup destroys worktrees still needed by subsequent phases\n- Tasks that never reach `complete` (permanently blocked or abandoned) accumulate worktrees indefinitely — any cancellation or watchdog path that abandons a task must also call `cleanupWorktree()`\n- On worktree reuse, run only `git fetch origin` to update remote-tracking refs — never `git reset --hard origin/master`; the branch already holds agent commits from prior phases, and a hard reset silently destroys them\n\n## Gate & Merge\n\n- Use an explicit `GATE_BYPASS_EVENT_TYPES = new Set(['directive', 'review_approved', 'ci_passed', 'ci_failure'])` in `PhaseRouterService` — replacing a bare `event.type !== 'directive'` check prevents `review_approved` and CI events from re-gating an already-approved task\n- When a `review_approved` event arrives and the deliver phase is already complete, call `mergeIfReady()` directly — extract merge logic into a shared public method so both `onDeliverComplete` and the `review_approved` path reuse it without duplication\n- Every phase-completion code path must call `phaseHooks.onPhaseComplete(task, phase)` explicitly — hooks inside `executePhase` do not fire on alternative paths (e.g., `review_approved` clearing the gate); a missing call silently skips lifecycle effects such as auto-merge even when the phase status is correctly written\n\n## Release Strategy\n\nA release is a configuration override that groups tasks and routes their PRs to an RC branch instead of master.\n\n### Data Model\n\n3 tables: `releases`, `release_tasks`, `release_repo_prs`.\n\n### Pipeline Integration\n\nWhen a task belongs to an active release:\n- `PhaseRouterService.buildEnv()` sets `PR_BASE_BRANCH` to the RC branch\n- The deliver skill creates PRs targeting the RC branch (not master)\n- `findActiveForTask(taskId)` is the key lookup — queries `release_tasks` → `releases`\n\n- When a task belongs to an active release, pass `rcBranch` as `baseBranch` to `getOrCreateWorktree()` (default `'master'`) — omitting it creates worktrees from master, not the RC branch; fetch the release config once in `executePhase()` and thread it through to both `getOrCreateWorktree()` and `buildEnv()` to avoid a duplicate `findActiveForTask()` call.\n- `GitHubService.findCommentByPrefix(owner, repo, issueNumber, prefix)` paginates issue comments (`per_page=100`) and returns `{ id, body } | null` — use it to locate a specific comment (e.g. `## Agent Plan`) before editing it.\n- `PhaseHooksService.onPlanComplete()` appends a `## Release Context` section (human-readable + collapsed JSON metadata) to the existing plan comment when the task has an active release; falls back to posting a standalone comment if the plan comment is not found (handles GitHub API propagation lag).\n\n### Status Lifecycle\n\n`draft` → `active` → `staging` → `validating` → `validated` → `shipping` → `shipped`\n(`failed` can transition back to `active` for retry)\n\n## Watchdog\n\n- Query `['active', 'gated']` statuses in `WatchdogService` — tasks waiting indefinitely for deliver-gate approval are invisible with `active`-only queries and accumulate as silent stuck issues\n- Expose `POST /internal/watchdog/run` (returns `{ stuck, slow }`) for on-demand detection — avoids E2E tests waiting 5 minutes for the cron cycle; `runCheck()` should be a public method on the service\n\n## Test Environment\n\nA test instance of agent-core runs alongside production for E2E verification before merging to master.\n\n- **Test clone:** `/home/agent/AgentSDE/testing/agent-core` (branch: `testing`)\n- **Port:** 8777, managed by pm2 as `agent-core-test`\n- **Database:** `agent-core-test.db` (configured via `DB_NAME` env var, separate from production `agent-core.db`)\n- **Nginx:** `wh.kontashera.com/test/*` proxies to `localhost:8777`\n- **Webhook:** Repo-level webhook on `AgentSDE/agent-core-e2e` delivers to `wh.kontashera.com/test/webhooks/github`\n- Never modify the test instance while production E2E or nightly runs are in progress\n- Always restore `agent-core-e2e/.env` `AGENT_CORE_URL` back to `http://localhost:3000` after testing\n- The `testing` branch can be force-pushed freely — it is not protected\n\n## Database Entities\n\nWhen adding a new TypeORM entity, update four locations atomically:\n1. Create entity file under `src/database/entities/<name>.entity.ts`\n2. Export from `src/database/entities/index.ts` barrel\n3. Add to the `forRoot` entities array in `DatabaseModule`\n4. Add to the `forFeature` array in `DatabaseModule`\n\nFor SQLite: use `type: 'integer'`/`type: 'real'` for numeric columns (TypeORM defaults don't map correctly); use `@Column({ name: 'snake_case_name' })` for consistent snake_case DB column naming.\n- When a service needs entities from another domain (e.g., `TaskEntity` in `ReleaseModule`), add them to the local `TypeOrmModule.forFeature()` array — do NOT import the owning module; avoids circular dependency risks.\n- When removing a TypeORM entity, mirror the four-location add pattern in reverse: delete the entity file, remove its export from `entities/index.ts`, remove it from the `forRoot` entities array in `DatabaseModule`, and remove `forFeature` references in all consumer modules (e.g., `OperationalModule`, `ControlApiModule`) and their spec files.\n- Before inserting into a join/relation table, always query existing rows and filter them out — silently inserting duplicates breaks unique constraints and is hard to diagnose at runtime.\n- When an issue specifies Postgres-style schema changes (migration files, `uuid_generate_v4()`, `timestamptz`), translate to a TypeORM entity with SQLite-compatible types (`@PrimaryGeneratedColumn('uuid')`, `datetime`) — no migration tooling exists in this repo; `synchronize: true` is the schema mechanism. Verify the schema in `database.module.spec.ts` with in-memory SQLite covering uuid generation, unique-constraint enforcement, and cross-tenant reuse.\n\n## REST Controllers\n\n- Throw HTTP exceptions (`NotFoundException`, etc.) in the controller layer, not in the service — services should return `null`/`undefined` for \"not found\" cases; the controller converts these to the appropriate HTTP status using NestJS built-in exceptions.\n- Register new controllers in their owning module and import that module into `AppModule` in the same PR — a controller not imported into `AppModule` is silently unreachable.\n\n## Redis / BullMQ\n\n- Use a dedicated `ioredis` instance (`lazyConnect: true`) in `HealthModule` for `/health` Redis probes — never share BullMQ's internal connection; health checks coupled to queue lifecycle report misleading states during degraded scenarios\n- Register queues via `BullModule.forRootAsync()` (ConfigService-driven) + `BullModule.registerQueue()` in `AppModule` — this is the established queue registration pattern\n- Redis pub/sub requires two separate ioredis clients — one dedicated to publish (`pub`), one to subscribe (`sub`); a client in subscribe mode cannot also publish. Follow the `EventBusModule` factory pattern. `emit()` must swallow Redis errors; per-handler error isolation in `on()` prevents one failing handler from blocking others — event delivery must never block pipeline advancement\n- If the BullMQ class manages its own `IORedis` connections directly, do NOT also register `BullModule.forRootAsync` in the same feature module — it conflicts with `InvokeModule`'s existing registration; use direct `IORedis` instances instead.\n- **BullMQ stall-retry idempotency**: Wrap `createTask()` in `route()` with try/catch; on unique-constraint error fall back to `findByIssueAndRepo()` — concurrent deliveries share the existing row. Guard phase hooks (`onPhaseBlocked`, `onCompoundComplete`) with per-phase event records before posting comments; skip if already recorded. Add early-return in `executePhase()` if phase is already `'active'`. Extend phase-event dedup guard to skip when `phaseStatus === 'failed' && task.status === 'gated'` — persistent-block stalls differ from transient retries (which keep `status === 'active'`).\n\n## Config Schema\n\n- Use Joi `.when()` for conditional env var validation in two directions: (a) make existing required fields optional when a new auth mode flag is present (`Joi.string().when('FLAG', { is: Joi.exist(), then: Joi.optional(), otherwise: Joi.required() })`); (b) make companion vars required when an optional provider's gateway var is set (e.g., `*_OAUTH_CLIENT_ID`). Designate one gateway var per provider group — partial configs missing the gateway silently pass (intentional; mirrors `GITHUB_APP_ID` pattern).\n- Config schema tests for conditional validators must cover three cases: (1) empty/legacy config passes with no error, (2) gateway set with all required companions passes, (3) gateway set but companions missing fails and lists the missing vars — case (3) is the partial-fail guard that prevents silent misconfiguration.\n\n## Important constraints\n\n- This service runs live on the VPS via pm2 (`pm2 restart agent-core` to reload)\n- Port `3000` is proxied through nginx — do not change the port without updating nginx config\n- `GITHUB_WEBHOOK_SECRET` must match the secret configured in the GitHub org webhook\n- The WAL file path (`agent-core-artefacts/watchdog/wal.jsonl`) is shared with the watchdog — do not change its location without updating both services\n- `ClaudeInvocationService.invoke()` is now the primary entry point for agent runs; `dispatch-v2.sh`/`DispatchService` is legacy and being superseded by the `invoke` module\n- `GITHUB_WEBHOOK_SECRET` must be read with `get()` (not `getOrThrow()`) and verification skipped when unset — E2E environments deliberately omit the secret; `getOrThrow()` returns HTTP 500 instead of gracefully ignoring the event\n- `TASK_DIR` is resolved by `ArtefactsService.buildCentralTaskDir()` and set in the Claude invocation env by `PhaseRouterService` — do not construct it independently in other services\n","numLines":201,"startLine":1,"totalLines":201}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01WHF9tX44GqrhTfTPVPSTTX","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The task directory doesn't exist. Let me try to read the context.json file and look at the PR to understand what was implemented in issue #640.","signature":"Es0CClsIDRgCKkD0j+RGVaXNlHTcMX6oEN4dT7Rj99gx/cXgWK+enLZAZvb/JYypLbOaBXSu98PnNtQ63+ilnc+DaxKwIVC+9ALbMhFjbGF1ZGUtc29ubmV0LTQtNjgAEgywDd8V0qZUv330qWAaDKTdelaQKDFz3MYKNyIwXAl7CvouZwwYL/xz0KQo20l73pQRoV/TdWx0Cwcwzcdq2xAW/MFL8JiNZHw5ASZKKp8BsrNLYWpPI719i1yIfiXFo8DPfRufdPgfEkPfQ0TKMYNhPljp/XQJT9jae8yzmmvF/4adYlqzw7IxsvI+5VwMTJPlIXjDgYOj89nnnY21fNiLNB6EZca/6hBHJCouMgC9t2DINCec2CJ6zo8v2MZumOZroCrlnxGY5ykOTFv5G+5S5WOQsgCNsmp6xRjiC+9SkJpIxXkPFghCCScANPkcGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":7769,"cache_read_input_tokens":23338,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":7769},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"15f99e15-0576-4da4-a441-c2ddbe216ab3"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01WHF9tX44GqrhTfTPVPSTTX","type":"message","role":"assistant","content":[{"type":"text","text":"Let me check the PR and other available artifacts for issue #640."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":7769,"cache_read_input_tokens":23338,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":7769},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"05a9281b-8f20-492b-a942-fb7c53f4747f"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01WHF9tX44GqrhTfTPVPSTTX","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013ffxKnQXbewxqKkQ4aNeFz","name":"Bash","input":{"command":"cd /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md && gh pr view 680 --json title,body,files 2>/dev/null || echo \"PR not found in this directory\"","description":"View PR #680 details"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":7769,"cache_read_input_tokens":23338,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":7769},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"93e2c30c-d3bd-400c-a023-0dfbb380c588"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013ffxKnQXbewxqKkQ4aNeFz","type":"tool_result","content":"{\"body\":\"Part of EPIC #610. Closes #640.\\n\\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\",\"files\":[{\"path\":\"src/bitbucket/bitbucket.service.ts\",\"additions\":44,\"deletions\":0},{\"path\":\"src/mcp/dispatchers/bitbucket-context.dispatcher.spec.ts\",\"additions\":208,\"deletions\":0},{\"path\":\"src/mcp/dispatchers/bitbucket-context.dispatcher.ts\",\"additions\":238,\"deletions\":0},{\"path\":\"src/mcp/mcp-subprocess.module.ts\",\"additions\":8,\"deletions\":1}],\"title\":\"feat(sm-3c): Bitbucket dispatcher — check_run_logs (#640)\"}\nShell cwd was reset to /home/agent/AgentSDE/agent-core","is_error":false}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"e1188991-535f-4fef-a068-c567d6fcc6fa","timestamp":"2026-04-24T17:28:27.964Z","tool_use_result":{"stdout":"{\"body\":\"Part of EPIC #610. Closes #640.\\n\\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\",\"files\":[{\"path\":\"src/bitbucket/bitbucket.service.ts\",\"additions\":44,\"deletions\":0},{\"path\":\"src/mcp/dispatchers/bitbucket-context.dispatcher.spec.ts\",\"additions\":208,\"deletions\":0},{\"path\":\"src/mcp/dispatchers/bitbucket-context.dispatcher.ts\",\"additions\":238,\"deletions\":0},{\"path\":\"src/mcp/mcp-subprocess.module.ts\",\"additions\":8,\"deletions\":1}],\"title\":\"feat(sm-3c): Bitbucket dispatcher — check_run_logs (#640)\"}","stderr":"\nShell cwd was reset to /home/agent/AgentSDE/agent-core","interrupted":false,"isImage":false,"noOutputExpected":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01WHF9tX44GqrhTfTPVPSTTX","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0162VSd4VUFJAQWmvx8uHH4c","name":"Bash","input":{"command":"gh pr view 680 --json title,body,files 2>/dev/null | head -100","description":"View PR #680 from agent-core"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":7769,"cache_read_input_tokens":23338,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":7769},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"e1ccbe25-e48c-4631-b66c-0c5c27a91967"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0162VSd4VUFJAQWmvx8uHH4c","type":"tool_result","content":"{\"body\":\"Part of EPIC #610. Closes #640.\\n\\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\",\"files\":[{\"path\":\"src/bitbucket/bitbucket.service.ts\",\"additions\":44,\"deletions\":0},{\"path\":\"src/mcp/dispatchers/bitbucket-context.dispatcher.spec.ts\",\"additions\":208,\"deletions\":0},{\"path\":\"src/mcp/dispatchers/bitbucket-context.dispatcher.ts\",\"additions\":238,\"deletions\":0},{\"path\":\"src/mcp/mcp-subprocess.module.ts\",\"additions\":8,\"deletions\":1}],\"title\":\"feat(sm-3c): Bitbucket dispatcher — check_run_logs (#640)\"}","is_error":false}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"0b6431e4-06ad-4a5c-be5e-fe5909180e7f","timestamp":"2026-04-24T17:28:28.591Z","tool_use_result":{"stdout":"{\"body\":\"Part of EPIC #610. Closes #640.\\n\\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\",\"files\":[{\"path\":\"src/bitbucket/bitbucket.service.ts\",\"additions\":44,\"deletions\":0},{\"path\":\"src/mcp/dispatchers/bitbucket-context.dispatcher.spec.ts\",\"additions\":208,\"deletions\":0},{\"path\":\"src/mcp/dispatchers/bitbucket-context.dispatcher.ts\",\"additions\":238,\"deletions\":0},{\"path\":\"src/mcp/mcp-subprocess.module.ts\",\"additions\":8,\"deletions\":1}],\"title\":\"feat(sm-3c): Bitbucket dispatcher — check_run_logs (#640)\"}","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_019p2qoyL9wYKofRRGaeavU4","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"So issue #640 added a Bitbucket dispatcher for check_run_logs. Let me look at the PR diff to understand what was implemented.","signature":"ErsCClsIDRgCKkChwxNmHMrmzji3/QYFq/A0z40VqGskGnFhLMrtaWO4FSPRyfvzSPZJY7bE5Z7M7r8+Ai2HMrHr7KQI4teKSsedMhFjbGF1ZGUtc29ubmV0LTQtNjgAEgyfKQD4InHHVWJG/lcaDMFHaNJmUJvpLcr/gyIw1AqDS2EoskgjEZUt1neQTqzsCkTyM2gotyTlwJeoMXNMMmP2KdvKaxMfaAyq4stIKo0BrmX+6fhexgKyykTUJu5+VJv5l0ZDjRD99m+S1BOTgmwJaT8KVijVggwBLJJ8lmHnQiqv1sxpu8Dga0gmpfxAJ45w6vT+X+h7mX+RSXhB3N3HG+zHAD6k6HO5nhngkNIERhZz0nwvXKhQ4t4OfRVrIgCA77eVAIKmcIUYSuKA3xZXJqs0UQQi1zggNBAcGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":721,"cache_read_input_tokens":31107,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":721},"output_tokens":39,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"3014a768-e7bd-4a80-b772-9e6f04e07a30"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_019p2qoyL9wYKofRRGaeavU4","type":"message","role":"assistant","content":[{"type":"text","text":"Let me examine the changed files from the PR to understand the patterns."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":721,"cache_read_input_tokens":31107,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":721},"output_tokens":39,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"e85f845e-930e-4af8-b012-aa1f32a3be9c"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_019p2qoyL9wYKofRRGaeavU4","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017CC71SuS8iwmgvJNQG63wT","name":"Bash","input":{"command":"gh pr diff 680 2>/dev/null | head -400","description":"Get PR #680 diff"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":721,"cache_read_input_tokens":31107,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":721},"output_tokens":39,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"27c9d028-a313-4be2-bd93-f8e2e9e755b7"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_019p2qoyL9wYKofRRGaeavU4","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UNXwZPiwKBVGUWVAN1B2yv","name":"Bash","input":{"command":"gh issue view 640 --repo AgentSDE/agent-core --json title,body 2>/dev/null | head -100","description":"View issue #640 details"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":721,"cache_read_input_tokens":31107,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":721},"output_tokens":39,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"44781a05-627f-4243-b12c-c0f028e8540a"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017CC71SuS8iwmgvJNQG63wT","type":"tool_result","content":"diff --git a/src/bitbucket/bitbucket.service.ts b/src/bitbucket/bitbucket.service.ts\nindex 2068a12..e8990cf 100644\n--- a/src/bitbucket/bitbucket.service.ts\n+++ b/src/bitbucket/bitbucket.service.ts\n@@ -565,6 +565,50 @@ export class BitbucketService {\n );\n }\n \n+ // --- Pipelines ---\n+\n+ /**\n+ * Fetch the log for a single Bitbucket Pipelines step, with a byte cap.\n+ *\n+ * Calls `GET /repositories/{workspace}/{repo_slug}/pipelines/{pipeline_uuid}\n+ * /steps/{step_uuid}/log`. The endpoint returns the raw log body as\n+ * `text/plain` (no JSON envelope).\n+ *\n+ * Returns `null` on 404 so callers can map to `NOT_FOUND`. If the log body\n+ * exceeds `maxBytes`, the head is kept and `truncated: true` is set —\n+ * matching the shape used by `GitHubService.getJobLogs`.\n+ *\n+ * Other HTTP errors propagate as {@link BitbucketApiError}.\n+ */\n+ async getPipelineStepLog(\n+ workspace: string,\n+ repoSlug: string,\n+ pipelineUuid: string,\n+ stepUuid: string,\n+ maxBytes?: number,\n+ ): Promise<{ text: string; truncated: boolean } | null> {\n+ const path =\n+ `/repositories/${workspace}/${repoSlug}` +\n+ `/pipelines/${pipelineUuid}/steps/${stepUuid}/log`;\n+ let response: Response;\n+ try {\n+ response = await this.rawRequest('GET', path);\n+ } catch (err) {\n+ if (err instanceof BitbucketApiError && err.status === 404) {\n+ return null;\n+ }\n+ throw err;\n+ }\n+ const buffer = Buffer.from(await response.arrayBuffer());\n+ if (typeof maxBytes === 'number' && buffer.byteLength > maxBytes) {\n+ return {\n+ text: buffer.subarray(0, maxBytes).toString('utf8'),\n+ truncated: true,\n+ };\n+ }\n+ return { text: buffer.toString('utf8'), truncated: false };\n+ }\n+\n // --- Private ---\n \n private async request<T = unknown>(\ndiff --git a/src/mcp/dispatchers/bitbucket-context.dispatcher.spec.ts b/src/mcp/dispatchers/bitbucket-context.dispatcher.spec.ts\nnew file mode 100644\nindex 0000000..96c3c60\n--- /dev/null\n+++ b/src/mcp/dispatchers/bitbucket-context.dispatcher.spec.ts\n@@ -0,0 +1,208 @@\n+import { Test } from '@nestjs/testing';\n+import {\n+ BitbucketContextDispatcher,\n+ DEFAULT_LOG_BYTE_LIMIT,\n+ decodeBitbucketCheckId,\n+ encodeBitbucketCheckId,\n+} from './bitbucket-context.dispatcher';\n+import { ToolRegistry } from '../tool-registry';\n+import {\n+ BitbucketApiError,\n+ BitbucketService,\n+} from '../../bitbucket/bitbucket.service';\n+import { BITBUCKET_SERVICE } from '../../bitbucket/bitbucket.module';\n+import { checkRunLogs } from '../tools/check-runs.schemas';\n+\n+describe('BitbucketContextDispatcher', () => {\n+ let dispatcher: BitbucketContextDispatcher;\n+ let registry: ToolRegistry;\n+ let bitbucket: { getPipelineStepLog: jest.Mock };\n+\n+ beforeEach(async () => {\n+ bitbucket = { getPipelineStepLog: jest.fn() };\n+\n+ const moduleRef = await Test.createTestingModule({\n+ providers: [\n+ ToolRegistry,\n+ BitbucketContextDispatcher,\n+ { provide: BITBUCKET_SERVICE, useValue: bitbucket },\n+ ],\n+ }).compile();\n+ await moduleRef.init();\n+\n+ registry = moduleRef.get(ToolRegistry);\n+ dispatcher = moduleRef.get(BitbucketContextDispatcher);\n+ });\n+\n+ afterEach(() => jest.restoreAllMocks());\n+\n+ describe('encode / decode check_id', () => {\n+ it('round-trips braced UUIDs', () => {\n+ const id = encodeBitbucketCheckId('{abcd-1234}', '{step-9999}');\n+ expect(id).toBe('{abcd-1234}:{step-9999}');\n+ expect(decodeBitbucketCheckId(id)).toEqual({\n+ pipelineUuid: '{abcd-1234}',\n+ stepUuid: '{step-9999}',\n+ });\n+ });\n+\n+ it('returns null on missing separator', () => {\n+ expect(decodeBitbucketCheckId('no-colon-here')).toBeNull();\n+ expect(decodeBitbucketCheckId('{abc}:')).toBeNull();\n+ expect(decodeBitbucketCheckId(':step')).toBeNull();\n+ });\n+ });\n+\n+ describe('module wiring', () => {\n+ it('registers v1.check_run_logs when not already registered', () => {\n+ expect(registry.has('v1.check_run_logs')).toBe(true);\n+ });\n+\n+ it('skips wiring when bitbucket service is null', async () => {\n+ const moduleRef = await Test.createTestingModule({\n+ providers: [\n+ ToolRegistry,\n+ BitbucketContextDispatcher,\n+ { provide: BITBUCKET_SERVICE, useValue: null },\n+ ],\n+ }).compile();\n+ await moduleRef.init();\n+ const reg = moduleRef.get(ToolRegistry);\n+ expect(reg.has('v1.check_run_logs')).toBe(false);\n+ });\n+ });\n+\n+ describe('v1.check_run_logs — bitbucket route', () => {\n+ const baseInput = {\n+ repo_ref: 'bitbucket:myspace/myrepo',\n+ check_id: '{pipe-1}:{step-2}',\n+ };\n+\n+ it('returns logs on happy path', async () => {\n+ bitbucket.getPipelineStepLog.mockResolvedValue({\n+ text: 'build passed\\n',\n+ truncated: false,\n+ });\n+ const result = (await dispatcher.handleCheckRunLogs(baseInput)) as Record<\n+ string,\n+ unknown\n+ >;\n+ expect(result.ok).toBe(true);\n+ expect(result.check_id).toBe('{pipe-1}:{step-2}');\n+ expect(result.logs).toBe('build passed\\n');\n+ expect(result.truncated).toBe(false);\n+ expect(result.conclusion).toBeNull();\n+ expect(bitbucket.getPipelineStepLog).toHaveBeenCalledWith(\n+ 'myspace',\n+ 'myrepo',\n+ '{pipe-1}',\n+ '{step-2}',\n+ DEFAULT_LOG_BYTE_LIMIT,\n+ );\n+ });\n+\n+ it('honours max_bytes and surfaces truncated=true', async () => {\n+ bitbucket.getPipelineStepLog.mockResolvedValue({\n+ text: 'x'.repeat(64),\n+ truncated: true,\n+ });\n+ const result = (await dispatcher.handleCheckRunLogs({\n+ ...baseInput,\n+ max_bytes: 64,\n+ })) as Record<string, unknown>;\n+ expect(result.ok).toBe(true);\n+ expect(result.truncated).toBe(true);\n+ expect((result.logs as string).length).toBe(64);\n+ expect(bitbucket.getPipelineStepLog).toHaveBeenCalledWith(\n+ 'myspace',\n+ 'myrepo',\n+ '{pipe-1}',\n+ '{step-2}',\n+ 64,\n+ );\n+ });\n+\n+ it('returns NOT_FOUND when the log fetch returns null', async () => {\n+ bitbucket.getPipelineStepLog.mockResolvedValue(null);\n+ const result = (await dispatcher.handleCheckRunLogs(baseInput)) as Record<\n+ string,\n+ unknown\n+ >;\n+ expect(result.ok).toBe(false);\n+ expect(result.code).toBe('NOT_FOUND');\n+ });\n+\n+ it('maps a 403 BitbucketApiError to FORBIDDEN', async () => {\n+ bitbucket.getPipelineStepLog.mockRejectedValue(\n+ new BitbucketApiError('GET', '/log', 403, 'forbidden'),\n+ );\n+ const result = (await dispatcher.handleCheckRunLogs(baseInput)) as Record<\n+ string,\n+ unknown\n+ >;\n+ expect(result.ok).toBe(false);\n+ expect(result.code).toBe('FORBIDDEN');\n+ expect(result.retriable).toBe(true);\n+ });\n+\n+ it('rejects malformed check_id with INVALID_INPUT', async () => {\n+ const result = (await dispatcher.handleCheckRunLogs({\n+ repo_ref: 'bitbucket:myspace/myrepo',\n+ check_id: 'not-encoded',\n+ })) as Record<string, unknown>;\n+ expect(result.ok).toBe(false);\n+ expect(result.code).toBe('INVALID_INPUT');\n+ });\n+\n+ it('rejects non-bitbucket repo_ref with INVALID_INPUT', async () => {\n+ const result = (await dispatcher.handleCheckRunLogs({\n+ repo_ref: 'github:AgentSDE/agent-core',\n+ check_id: '{a}:{b}',\n+ })) as Record<string, unknown>;\n+ expect(result.ok).toBe(false);\n+ expect(result.code).toBe('INVALID_INPUT');\n+ });\n+ });\n+\n+ describe('routing via ToolRegistry', () => {\n+ it('routes bitbucket refs through the BB handler', async () => {\n+ bitbucket.getPipelineStepLog.mockResolvedValue({\n+ text: 'ok',\n+ truncated: false,\n+ });\n+ const result = (await registry.call('v1.check_run_logs', {\n+ repo_ref: 'bitbucket:ws/r',\n+ check_id: '{a}:{b}',\n+ })) as Record<string, unknown>;\n+ expect(result.ok).toBe(true);\n+ expect(bitbucket.getPipelineStepLog).toHaveBeenCalled();\n+ });\n+\n+ it('rejects non-bitbucket refs with INVALID_INPUT when no fallback', async () => {\n+ const result = (await registry.call('v1.check_run_logs', {\n+ repo_ref: 'github:AgentSDE/agent-core',\n+ check_id: '1',\n+ })) as Record<string, unknown>;\n+ expect(result.ok).toBe(false);\n+ expect(result.code).toBe('INVALID_INPUT');\n+ });\n+\n+ it('delegates to an existing handler for non-bitbucket refs', async () => {\n+ const fallback = jest.fn().mockResolvedValue({ ok: true, from: 'gh' });\n+ // Fresh registry with a GH-like handler pre-registered.\n+ const reg = new ToolRegistry();\n+ reg.register('v1.check_run_logs', checkRunLogs.inputSchema, fallback);\n+ const disp = new BitbucketContextDispatcher(\n+ reg,\n+ bitbucket as unknown as BitbucketService,\n+ );\n+ disp.onModuleInit();\n+ const result = (await reg.call('v1.check_run_logs', {\n+ repo_ref: 'github:AgentSDE/agent-core',\n+ check_id: '7',\n+ })) as Record<string, unknown>;\n+ expect(result).toEqual({ ok: true, from: 'gh' });\n+ expect(fallback).toHaveBeenCalled();\n+ });\n+ });\n+});\ndiff --git a/src/mcp/dispatchers/bitbucket-context.dispatcher.ts b/src/mcp/dispatchers/bitbucket-context.dispatcher.ts\nnew file mode 100644\nindex 0000000..d911011\n--- /dev/null\n+++ b/src/mcp/dispatchers/bitbucket-context.dispatcher.ts\n@@ -0,0 +1,238 @@\n+/**\n+ * Bitbucket dispatcher for the check-run tool family (SM-3C).\n+ *\n+ * Implements:\n+ * - `v1.check_run_logs` — fetch the step log for a Bitbucket Pipelines\n+ * run, wrapped with a byte cap.\n+ *\n+ * `check_id` is an opaque string; for Bitbucket it encodes\n+ * `<pipeline_uuid>:<step_uuid>` so the dispatcher can decode both IDs\n+ * required by the Pipelines step-log endpoint. The `{}` braces that\n+ * Bitbucket uses for UUIDs are preserved as-is.\n+ *\n+ * Error mapping: 404 → `NOT_FOUND`, 401 → `UNAUTHENTICATED`,\n+ * 403 → `FORBIDDEN`, 429 → `RATE_LIMITED`, else `INTERNAL`.\n+ */\n+import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';\n+import { z } from 'zod';\n+import { ToolRegistry } from '../tool-registry';\n+import {\n+ BitbucketApiError,\n+ BitbucketService,\n+} from '../../bitbucket/bitbucket.service';\n+import { BITBUCKET_SERVICE } from '../../bitbucket/bitbucket.module';\n+import type { ErrorCode } from '../tools/common';\n+import { checkRunLogs } from '../tools/check-runs.schemas';\n+import { parseRepoRef } from './repo-ref';\n+import { McpError } from '../errors';\n+\n+/**\n+ * Default byte cap for `v1.check_run_logs` output. Matches the GitHub\n+ * dispatcher's cap (SM-1F) so callers see consistent limits across\n+ * platforms.\n+ */\n+export const DEFAULT_LOG_BYTE_LIMIT = 256 * 1024;\n+\n+type ErrorEnvelope = {\n+ ok: false;\n+ code: ErrorCode;\n+ message: string;\n+ retriable: boolean;\n+ platform: 'bitbucket';\n+};\n+\n+function errorResponse(\n+ code: ErrorCode,\n+ message: string,\n+ retriable = false,\n+): ErrorEnvelope {\n+ return {\n+ ok: false,\n+ code,\n+ message,\n+ retriable,\n+ platform: 'bitbucket',\n+ };\n+}\n+\n+/**\n+ * Encode a Bitbucket `check_id` from the pipeline + step UUIDs.\n+ * The UUIDs keep their braces (Bitbucket's canonical form).\n+ */\n+export function encodeBitbucketCheckId(\n+ pipelineUuid: string,\n+ stepUuid: string,\n+): string {\n+ return `${pipelineUuid}:${stepUuid}`;\n+}\n+\n+/**\n+ * Decode a Bitbucket `check_id` back into `{ pipelineUuid, stepUuid }`.\n+ * Returns `null` on malformed input so the dispatcher can emit\n+ * `INVALID_INPUT` without a try/catch.\n+ */\n+export function decodeBitbucketCheckId(\n+ checkId: string,\n+): { pipelineUuid: string; stepUuid: string } | null {\n+ // UUIDs may be of the form `{...}` which contains no colons — split on\n+ // the first top-level colon that is not inside braces.\n+ let depth = 0;\n+ for (let i = 0; i < checkId.length; i++) {\n+ const ch = checkId[i];\n+ if (ch === '{') depth++;\n+ else if (ch === '}') depth--;\n+ else if (ch === ':' && depth === 0) {\n+ const pipelineUuid = checkId.slice(0, i);\n+ const stepUuid = checkId.slice(i + 1);\n+ if (!pipelineUuid || !stepUuid) return null;\n+ return { pipelineUuid, stepUuid };\n+ }\n+ }\n+ return null;\n+}\n+\n+@Injectable()\n+export class BitbucketContextDispatcher implements OnModuleInit {\n+ private readonly logger = new Logger(BitbucketContextDispatcher.name);\n+\n+ constructor(\n+ private readonly registry: ToolRegistry,\n+ @Inject(BITBUCKET_SERVICE)\n+ private readonly bitbucket: BitbucketService | null,\n+ ) {}\n+\n+ onModuleInit(): void {\n+ // The GitHub dispatcher (SM-1F) registers `v1.check_run_logs` first\n+ // and becomes the default handler. This dispatcher only attaches when\n+ // `repo_ref` is a Bitbucket handle — so we wrap the existing handler\n+ // and route based on platform. When Bitbucket is disabled we skip\n+ // entirely and let the GitHub handler reject BB refs with\n+ // INVALID_INPUT as before.\n+ if (!this.bitbucket) {\n+ this.logger.log(\n+ 'Bitbucket service not configured; skipping BB check_run_logs wiring',\n+ );\n+ return;\n+ }\n+ if (this.registry.has('v1.check_run_logs')) {\n+ const existing = this.registry.get('v1.check_run_logs').handler;\n+ this.registry.replaceHandler(\n+ 'v1.check_run_logs',\n+ (input) => this.routeCheckRunLogs(input, existing),\n+ checkRunLogs.inputSchema,\n+ );\n+ } else {\n+ this.registry.register(","is_error":false}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"4c0f0d6e-86e7-49f6-86d7-1441028989b3","timestamp":"2026-04-24T17:28:32.252Z","tool_use_result":{"stdout":"diff --git a/src/bitbucket/bitbucket.service.ts b/src/bitbucket/bitbucket.service.ts\nindex 2068a12..e8990cf 100644\n--- a/src/bitbucket/bitbucket.service.ts\n+++ b/src/bitbucket/bitbucket.service.ts\n@@ -565,6 +565,50 @@ export class BitbucketService {\n );\n }\n \n+ // --- Pipelines ---\n+\n+ /**\n+ * Fetch the log for a single Bitbucket Pipelines step, with a byte cap.\n+ *\n+ * Calls `GET /repositories/{workspace}/{repo_slug}/pipelines/{pipeline_uuid}\n+ * /steps/{step_uuid}/log`. The endpoint returns the raw log body as\n+ * `text/plain` (no JSON envelope).\n+ *\n+ * Returns `null` on 404 so callers can map to `NOT_FOUND`. If the log body\n+ * exceeds `maxBytes`, the head is kept and `truncated: true` is set —\n+ * matching the shape used by `GitHubService.getJobLogs`.\n+ *\n+ * Other HTTP errors propagate as {@link BitbucketApiError}.\n+ */\n+ async getPipelineStepLog(\n+ workspace: string,\n+ repoSlug: string,\n+ pipelineUuid: string,\n+ stepUuid: string,\n+ maxBytes?: number,\n+ ): Promise<{ text: string; truncated: boolean } | null> {\n+ const path =\n+ `/repositories/${workspace}/${repoSlug}` +\n+ `/pipelines/${pipelineUuid}/steps/${stepUuid}/log`;\n+ let response: Response;\n+ try {\n+ response = await this.rawRequest('GET', path);\n+ } catch (err) {\n+ if (err instanceof BitbucketApiError && err.status === 404) {\n+ return null;\n+ }\n+ throw err;\n+ }\n+ const buffer = Buffer.from(await response.arrayBuffer());\n+ if (typeof maxBytes === 'number' && buffer.byteLength > maxBytes) {\n+ return {\n+ text: buffer.subarray(0, maxBytes).toString('utf8'),\n+ truncated: true,\n+ };\n+ }\n+ return { text: buffer.toString('utf8'), truncated: false };\n+ }\n+\n // --- Private ---\n \n private async request<T = unknown>(\ndiff --git a/src/mcp/dispatchers/bitbucket-context.dispatcher.spec.ts b/src/mcp/dispatchers/bitbucket-context.dispatcher.spec.ts\nnew file mode 100644\nindex 0000000..96c3c60\n--- /dev/null\n+++ b/src/mcp/dispatchers/bitbucket-context.dispatcher.spec.ts\n@@ -0,0 +1,208 @@\n+import { Test } from '@nestjs/testing';\n+import {\n+ BitbucketContextDispatcher,\n+ DEFAULT_LOG_BYTE_LIMIT,\n+ decodeBitbucketCheckId,\n+ encodeBitbucketCheckId,\n+} from './bitbucket-context.dispatcher';\n+import { ToolRegistry } from '../tool-registry';\n+import {\n+ BitbucketApiError,\n+ BitbucketService,\n+} from '../../bitbucket/bitbucket.service';\n+import { BITBUCKET_SERVICE } from '../../bitbucket/bitbucket.module';\n+import { checkRunLogs } from '../tools/check-runs.schemas';\n+\n+describe('BitbucketContextDispatcher', () => {\n+ let dispatcher: BitbucketContextDispatcher;\n+ let registry: ToolRegistry;\n+ let bitbucket: { getPipelineStepLog: jest.Mock };\n+\n+ beforeEach(async () => {\n+ bitbucket = { getPipelineStepLog: jest.fn() };\n+\n+ const moduleRef = await Test.createTestingModule({\n+ providers: [\n+ ToolRegistry,\n+ BitbucketContextDispatcher,\n+ { provide: BITBUCKET_SERVICE, useValue: bitbucket },\n+ ],\n+ }).compile();\n+ await moduleRef.init();\n+\n+ registry = moduleRef.get(ToolRegistry);\n+ dispatcher = moduleRef.get(BitbucketContextDispatcher);\n+ });\n+\n+ afterEach(() => jest.restoreAllMocks());\n+\n+ describe('encode / decode check_id', () => {\n+ it('round-trips braced UUIDs', () => {\n+ const id = encodeBitbucketCheckId('{abcd-1234}', '{step-9999}');\n+ expect(id).toBe('{abcd-1234}:{step-9999}');\n+ expect(decodeBitbucketCheckId(id)).toEqual({\n+ pipelineUuid: '{abcd-1234}',\n+ stepUuid: '{step-9999}',\n+ });\n+ });\n+\n+ it('returns null on missing separator', () => {\n+ expect(decodeBitbucketCheckId('no-colon-here')).toBeNull();\n+ expect(decodeBitbucketCheckId('{abc}:')).toBeNull();\n+ expect(decodeBitbucketCheckId(':step')).toBeNull();\n+ });\n+ });\n+\n+ describe('module wiring', () => {\n+ it('registers v1.check_run_logs when not already registered', () => {\n+ expect(registry.has('v1.check_run_logs')).toBe(true);\n+ });\n+\n+ it('skips wiring when bitbucket service is null', async () => {\n+ const moduleRef = await Test.createTestingModule({\n+ providers: [\n+ ToolRegistry,\n+ BitbucketContextDispatcher,\n+ { provide: BITBUCKET_SERVICE, useValue: null },\n+ ],\n+ }).compile();\n+ await moduleRef.init();\n+ const reg = moduleRef.get(ToolRegistry);\n+ expect(reg.has('v1.check_run_logs')).toBe(false);\n+ });\n+ });\n+\n+ describe('v1.check_run_logs — bitbucket route', () => {\n+ const baseInput = {\n+ repo_ref: 'bitbucket:myspace/myrepo',\n+ check_id: '{pipe-1}:{step-2}',\n+ };\n+\n+ it('returns logs on happy path', async () => {\n+ bitbucket.getPipelineStepLog.mockResolvedValue({\n+ text: 'build passed\\n',\n+ truncated: false,\n+ });\n+ const result = (await dispatcher.handleCheckRunLogs(baseInput)) as Record<\n+ string,\n+ unknown\n+ >;\n+ expect(result.ok).toBe(true);\n+ expect(result.check_id).toBe('{pipe-1}:{step-2}');\n+ expect(result.logs).toBe('build passed\\n');\n+ expect(result.truncated).toBe(false);\n+ expect(result.conclusion).toBeNull();\n+ expect(bitbucket.getPipelineStepLog).toHaveBeenCalledWith(\n+ 'myspace',\n+ 'myrepo',\n+ '{pipe-1}',\n+ '{step-2}',\n+ DEFAULT_LOG_BYTE_LIMIT,\n+ );\n+ });\n+\n+ it('honours max_bytes and surfaces truncated=true', async () => {\n+ bitbucket.getPipelineStepLog.mockResolvedValue({\n+ text: 'x'.repeat(64),\n+ truncated: true,\n+ });\n+ const result = (await dispatcher.handleCheckRunLogs({\n+ ...baseInput,\n+ max_bytes: 64,\n+ })) as Record<string, unknown>;\n+ expect(result.ok).toBe(true);\n+ expect(result.truncated).toBe(true);\n+ expect((result.logs as string).length).toBe(64);\n+ expect(bitbucket.getPipelineStepLog).toHaveBeenCalledWith(\n+ 'myspace',\n+ 'myrepo',\n+ '{pipe-1}',\n+ '{step-2}',\n+ 64,\n+ );\n+ });\n+\n+ it('returns NOT_FOUND when the log fetch returns null', async () => {\n+ bitbucket.getPipelineStepLog.mockResolvedValue(null);\n+ const result = (await dispatcher.handleCheckRunLogs(baseInput)) as Record<\n+ string,\n+ unknown\n+ >;\n+ expect(result.ok).toBe(false);\n+ expect(result.code).toBe('NOT_FOUND');\n+ });\n+\n+ it('maps a 403 BitbucketApiError to FORBIDDEN', async () => {\n+ bitbucket.getPipelineStepLog.mockRejectedValue(\n+ new BitbucketApiError('GET', '/log', 403, 'forbidden'),\n+ );\n+ const result = (await dispatcher.handleCheckRunLogs(baseInput)) as Record<\n+ string,\n+ unknown\n+ >;\n+ expect(result.ok).toBe(false);\n+ expect(result.code).toBe('FORBIDDEN');\n+ expect(result.retriable).toBe(true);\n+ });\n+\n+ it('rejects malformed check_id with INVALID_INPUT', async () => {\n+ const result = (await dispatcher.handleCheckRunLogs({\n+ repo_ref: 'bitbucket:myspace/myrepo',\n+ check_id: 'not-encoded',\n+ })) as Record<string, unknown>;\n+ expect(result.ok).toBe(false);\n+ expect(result.code).toBe('INVALID_INPUT');\n+ });\n+\n+ it('rejects non-bitbucket repo_ref with INVALID_INPUT', async () => {\n+ const result = (await dispatcher.handleCheckRunLogs({\n+ repo_ref: 'github:AgentSDE/agent-core',\n+ check_id: '{a}:{b}',\n+ })) as Record<string, unknown>;\n+ expect(result.ok).toBe(false);\n+ expect(result.code).toBe('INVALID_INPUT');\n+ });\n+ });\n+\n+ describe('routing via ToolRegistry', () => {\n+ it('routes bitbucket refs through the BB handler', async () => {\n+ bitbucket.getPipelineStepLog.mockResolvedValue({\n+ text: 'ok',\n+ truncated: false,\n+ });\n+ const result = (await registry.call('v1.check_run_logs', {\n+ repo_ref: 'bitbucket:ws/r',\n+ check_id: '{a}:{b}',\n+ })) as Record<string, unknown>;\n+ expect(result.ok).toBe(true);\n+ expect(bitbucket.getPipelineStepLog).toHaveBeenCalled();\n+ });\n+\n+ it('rejects non-bitbucket refs with INVALID_INPUT when no fallback', async () => {\n+ const result = (await registry.call('v1.check_run_logs', {\n+ repo_ref: 'github:AgentSDE/agent-core',\n+ check_id: '1',\n+ })) as Record<string, unknown>;\n+ expect(result.ok).toBe(false);\n+ expect(result.code).toBe('INVALID_INPUT');\n+ });\n+\n+ it('delegates to an existing handler for non-bitbucket refs', async () => {\n+ const fallback = jest.fn().mockResolvedValue({ ok: true, from: 'gh' });\n+ // Fresh registry with a GH-like handler pre-registered.\n+ const reg = new ToolRegistry();\n+ reg.register('v1.check_run_logs', checkRunLogs.inputSchema, fallback);\n+ const disp = new BitbucketContextDispatcher(\n+ reg,\n+ bitbucket as unknown as BitbucketService,\n+ );\n+ disp.onModuleInit();\n+ const result = (await reg.call('v1.check_run_logs', {\n+ repo_ref: 'github:AgentSDE/agent-core',\n+ check_id: '7',\n+ })) as Record<string, unknown>;\n+ expect(result).toEqual({ ok: true, from: 'gh' });\n+ expect(fallback).toHaveBeenCalled();\n+ });\n+ });\n+});\ndiff --git a/src/mcp/dispatchers/bitbucket-context.dispatcher.ts b/src/mcp/dispatchers/bitbucket-context.dispatcher.ts\nnew file mode 100644\nindex 0000000..d911011\n--- /dev/null\n+++ b/src/mcp/dispatchers/bitbucket-context.dispatcher.ts\n@@ -0,0 +1,238 @@\n+/**\n+ * Bitbucket dispatcher for the check-run tool family (SM-3C).\n+ *\n+ * Implements:\n+ * - `v1.check_run_logs` — fetch the step log for a Bitbucket Pipelines\n+ * run, wrapped with a byte cap.\n+ *\n+ * `check_id` is an opaque string; for Bitbucket it encodes\n+ * `<pipeline_uuid>:<step_uuid>` so the dispatcher can decode both IDs\n+ * required by the Pipelines step-log endpoint. The `{}` braces that\n+ * Bitbucket uses for UUIDs are preserved as-is.\n+ *\n+ * Error mapping: 404 → `NOT_FOUND`, 401 → `UNAUTHENTICATED`,\n+ * 403 → `FORBIDDEN`, 429 → `RATE_LIMITED`, else `INTERNAL`.\n+ */\n+import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';\n+import { z } from 'zod';\n+import { ToolRegistry } from '../tool-registry';\n+import {\n+ BitbucketApiError,\n+ BitbucketService,\n+} from '../../bitbucket/bitbucket.service';\n+import { BITBUCKET_SERVICE } from '../../bitbucket/bitbucket.module';\n+import type { ErrorCode } from '../tools/common';\n+import { checkRunLogs } from '../tools/check-runs.schemas';\n+import { parseRepoRef } from './repo-ref';\n+import { McpError } from '../errors';\n+\n+/**\n+ * Default byte cap for `v1.check_run_logs` output. Matches the GitHub\n+ * dispatcher's cap (SM-1F) so callers see consistent limits across\n+ * platforms.\n+ */\n+export const DEFAULT_LOG_BYTE_LIMIT = 256 * 1024;\n+\n+type ErrorEnvelope = {\n+ ok: false;\n+ code: ErrorCode;\n+ message: string;\n+ retriable: boolean;\n+ platform: 'bitbucket';\n+};\n+\n+function errorResponse(\n+ code: ErrorCode,\n+ message: string,\n+ retriable = false,\n+): ErrorEnvelope {\n+ return {\n+ ok: false,\n+ code,\n+ message,\n+ retriable,\n+ platform: 'bitbucket',\n+ };\n+}\n+\n+/**\n+ * Encode a Bitbucket `check_id` from the pipeline + step UUIDs.\n+ * The UUIDs keep their braces (Bitbucket's canonical form).\n+ */\n+export function encodeBitbucketCheckId(\n+ pipelineUuid: string,\n+ stepUuid: string,\n+): string {\n+ return `${pipelineUuid}:${stepUuid}`;\n+}\n+\n+/**\n+ * Decode a Bitbucket `check_id` back into `{ pipelineUuid, stepUuid }`.\n+ * Returns `null` on malformed input so the dispatcher can emit\n+ * `INVALID_INPUT` without a try/catch.\n+ */\n+export function decodeBitbucketCheckId(\n+ checkId: string,\n+): { pipelineUuid: string; stepUuid: string } | null {\n+ // UUIDs may be of the form `{...}` which contains no colons — split on\n+ // the first top-level colon that is not inside braces.\n+ let depth = 0;\n+ for (let i = 0; i < checkId.length; i++) {\n+ const ch = checkId[i];\n+ if (ch === '{') depth++;\n+ else if (ch === '}') depth--;\n+ else if (ch === ':' && depth === 0) {\n+ const pipelineUuid = checkId.slice(0, i);\n+ const stepUuid = checkId.slice(i + 1);\n+ if (!pipelineUuid || !stepUuid) return null;\n+ return { pipelineUuid, stepUuid };\n+ }\n+ }\n+ return null;\n+}\n+\n+@Injectable()\n+export class BitbucketContextDispatcher implements OnModuleInit {\n+ private readonly logger = new Logger(BitbucketContextDispatcher.name);\n+\n+ constructor(\n+ private readonly registry: ToolRegistry,\n+ @Inject(BITBUCKET_SERVICE)\n+ private readonly bitbucket: BitbucketService | null,\n+ ) {}\n+\n+ onModuleInit(): void {\n+ // The GitHub dispatcher (SM-1F) registers `v1.check_run_logs` first\n+ // and becomes the default handler. This dispatcher only attaches when\n+ // `repo_ref` is a Bitbucket handle — so we wrap the existing handler\n+ // and route based on platform. When Bitbucket is disabled we skip\n+ // entirely and let the GitHub handler reject BB refs with\n+ // INVALID_INPUT as before.\n+ if (!this.bitbucket) {\n+ this.logger.log(\n+ 'Bitbucket service not configured; skipping BB check_run_logs wiring',\n+ );\n+ return;\n+ }\n+ if (this.registry.has('v1.check_run_logs')) {\n+ const existing = this.registry.get('v1.check_run_logs').handler;\n+ this.registry.replaceHandler(\n+ 'v1.check_run_logs',\n+ (input) => this.routeCheckRunLogs(input, existing),\n+ checkRunLogs.inputSchema,\n+ );\n+ } else {\n+ this.registry.register(","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UNXwZPiwKBVGUWVAN1B2yv","type":"tool_result","content":"{\"body\":\"Part of EPIC #610 — Skills → MCP Platform Tools\\n\\n## Scope\\n\\n`v1.check_run_logs` for BB Pipelines.\\n\\n## Context\\n\\nPlan: `agent-core-artefacts/docs/planning/skills-mcp-platform/plan.md`\\nExecution: `agent-core-artefacts/docs/planning/skills-mcp-platform/execution-plan.md`\\n\\n## Branching\\n\\n- Branch: `feat/<this-issue-number>-sm-3c`\\n- Target: `rc/skills-mcp-platform`\\n- PR body: `Closes #<this-issue-number>`\",\"title\":\"SM-3C: Bitbucket dispatcher — checks\"}","is_error":false}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"69470fb3-6641-439f-ba72-0c56dd460285","timestamp":"2026-04-24T17:28:32.749Z","tool_use_result":{"stdout":"{\"body\":\"Part of EPIC #610 — Skills → MCP Platform Tools\\n\\n## Scope\\n\\n`v1.check_run_logs` for BB Pipelines.\\n\\n## Context\\n\\nPlan: `agent-core-artefacts/docs/planning/skills-mcp-platform/plan.md`\\nExecution: `agent-core-artefacts/docs/planning/skills-mcp-platform/execution-plan.md`\\n\\n## Branching\\n\\n- Branch: `feat/<this-issue-number>-sm-3c`\\n- Target: `rc/skills-mcp-platform`\\n- PR body: `Closes #<this-issue-number>`\",\"title\":\"SM-3C: Bitbucket dispatcher — checks\"}","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01AaZawrycrDRwb88wt4ju3T","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I have a good understanding of what was implemented in issue #640. Let me look at the rest of the diff to understand the full implementation, including any patterns worth documenting.\n\nKey observations from the PR:\n1. This implemented a Bitbucket dispatcher for `v1.check_run_logs` (SM-3C), which is part of the Skills MCP Platform epic.\n2. The dispatcher wraps existing handlers in `ToolRegistry` - when `repo_ref` is a Bitbucket handle, it routes to the BB handler; otherwise it delegates to the existing (GitHub) handler.\n3. The `check_id` for Bitbucket is encoded as `<pipeline_uuid>:<step_uuid>`, using a custom encoder/decoder that handles UUID braces correctly.\n\n\n4. Error handling maps HTTP status codes consistently: 404 becomes `NOT_FOUND`, 401 becomes `UNAUTHENTICATED`, 403 becomes `FORBIDDEN`, 429 becomes `RATE_LIMITED`, and everything else defaults to `INTERNAL`.\n5. The underlying `BitbucketService.getPipelineStepLog()` fetches raw text logs and returns null on 404 errors.\n6. The dispatcher uses optional dependency injection via the `BITBUCKET_SERVICE` token, so it gracefully skips wiring when the service isn't available.\n7. The test suite covers the happy path, log truncation, error cases like NOT_FOUND and FORBIDDEN, invalid check_id formats, and proper routing through ToolRegistry.\n\nNow I'm examining the rest of the dispatcher implementation...","signature":"EqgLClsIDRgCKkDufgSVdx0bE+e/5DmMshbTvzVGU1sBeScljr1md0HRw4R6XOeD54YwVSdFRAGI+AqscCPCOQRJUsa8Q8fvF7S8MhFjbGF1ZGUtc29ubmV0LTQtNjgAEgxJccSI7QXFsRUSzSAaDFJdJ0wD10Nu/1bwnSIwdMBOpmMWeJO5XxeGCPpxmvbFDzexd/6T7E99ALt0ztOe3AF0EseBqrSKaxrxL80OKvoJMDDXx41i2XnMV5jQQ8corfRwgJXuGTAFDiu1i/2eeGGqBkEFwD9gisJ6liVYBKyQmbIzuybO5tsLmFoVzYllPiybpXkK4MqPKMGo73T1WO7AeZ7bSIn6R4h1Zb97xHSMmYX40+JE7NLVSNGNeA1G5fnRuRDtpAFMAUwvs1gLbalfgXlOUTdO2u0jlZurc4vLM87fPCfAQ5dmqMUkxSlky4gHgM/xweVFTdBIWDUzPajx0yL4M6prPX1bkkFg6P3olI+MbBM7AZhvCCLMB8bOlMp2W20Lqemirvoc/znGr7pEF0+3J8TNxADPdK3XfdRbQDuFffYy0rD3EEIIg9B2RlH9O6XwIa31EO8lyD/nRBN5HRQ1Wu69Lh03XqC6dWmO3nN/zJv+z+Pe3N8r0EistJj8vfdhrzrTimbOku+qibgmjfxq+ysJ5jeAOdHtD64Tq7XLKZijT/0e7xOuhgOJgwA4462DJFe/2s098oKH7424VaPeKEnrULVcI2+Y7YuXeAvVu1Ud5rvl23OxeGfctyZ4dG6bhc1jnrFyZgXBMjpvERJH4J3/oWhRTMp1YeIQng8jSYv4u7HcYLkxXX3RAU4n9E+NQ2IVdOQMMAU9ySZXfkXrL2AxjcHFzt5o550YDeuuAkEQ4gfz/zKPcqPJAPuLmNRvAb7dChe1/4nyauaDsOoKMVMvEqaKT/3Lws595XG5hGHJuaDKIyPxuwu+QFkjmHIweo6GQ5AqZGLTFj20taCIDCpJVlFpwR5FkideTeALCil5SoswzTC6ZYhukodvHrEbC1HjuKfx30/gIs4gHlby/ZNt7BPiv7se14842B6PDKWBJ1YFZwu/44QeK3ufF3r71pUl6Z3ouSbexCnrSdc5nyhC55Z5u/V2ykxTxF3dUubQhZyWfoXRV96adyYm81Db329oBA4IFTjlw+KF+16V4CD9To4dQ1QahwphxklivB6zT/N++h3pHn2+KVmO41kcTraN3NW79AinBFm855JwJGX4E9GoUZRXkclCROHh5A+5R2F7QqbvXjP/yHHFAR7QSmK68EVIOdzeiIl+UXNVA+aUc8njowhpwV942WF61aezmveHiIu5QVYptp1sPYGRaBEslzUvu0zXkFeXemH51FzVsbTsF6VGgSfQKLbkLrBFAp3YO+zuWc+zzaPXRHy4JBFidgPwO2uMuuQvr9qnI9BrToH48JQVClAmetUr+yov0LuVygiq0slbmTxTSwB9wvGthqxzC1scjSl5CB386E2C740Q3mWuxvTZ85sDOihV4c7pYxWWJBaND014NcH/Ov1WdJfJlufPqmzqTNss/pT3w9pRoZO9R4iNZmFh+EPfQnBBj03EzTQQ2jRf4UULyo3VOAHtXo0G7bG8KzbIkKXC3GSchjZgqyI9nUD8nL5GLd+IWQD6mtk7Dq8UdfaDdFNswvdHcxAQcaE3wIwmwslyqJhZ/465OMlQaMLYzGMlmJ7PAdtat7goCI3Ieqi/fpkckG0lBBqv3t8buu4GGjhBRFyqpzkMu32Xh4SZcv3WQoaOyM4ywZek5BgdOtsqIstwLElAW1HiqlhsV84f/wayWI51pY4v02yFKCmi8/jYv7/kHHCnPKYgBXkGdHGwb3XB0k64Hg5zkl16+tSOSZhLlgOtoDSpCJv8JItfTJvg9W36Fq0Y98W0m5xxS2PK3vD95g4YAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":5410,"cache_read_input_tokens":31828,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":5410},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"39c6852c-d391-46aa-a4a8-13bdaf8ab9d4"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01AaZawrycrDRwb88wt4ju3T","type":"message","role":"assistant","content":[{"type":"text","text":"Let me also check the PR comments for review feedback patterns."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":5410,"cache_read_input_tokens":31828,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":5410},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"95e98a2d-1bfe-4c88-9aa5-a13e513f1b75"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01AaZawrycrDRwb88wt4ju3T","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01L9YgMrZypotnD7QHm8prBy","name":"Bash","input":{"command":"gh pr view 680 --json reviews,comments 2>/dev/null | head -100","description":"Get PR #680 review comments"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":5410,"cache_read_input_tokens":31828,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":5410},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"13e30cd7-2087-473b-b8a4-7b96c7f9d67d"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01L9YgMrZypotnD7QHm8prBy","type":"tool_result","content":"{\"comments\":[],\"reviews\":[]}","is_error":false}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"acd1075c-234d-4740-9062-2802a8830492","timestamp":"2026-04-24T17:28:43.029Z","tool_use_result":{"stdout":"{\"comments\":[],\"reviews\":[]}","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01AaZawrycrDRwb88wt4ju3T","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01RSk9ssfdqvrHBRv6RjieKe","name":"Bash","input":{"command":"git log --oneline -10 feat/640-sm-3c 2>/dev/null || git log --oneline -5 origin/feat/640-sm-3c 2>/dev/null || echo \"branch not found\"","description":"Check branch history for issue 640"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":5410,"cache_read_input_tokens":31828,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":5410},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"1f2d70d7-fd9b-46d4-afa1-2a4f17b2242b"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01RSk9ssfdqvrHBRv6RjieKe","type":"tool_result","content":"branch not found","is_error":false}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"15e50e95-5740-4dda-828f-2f0140821644","timestamp":"2026-04-24T17:28:43.507Z","tool_use_result":{"stdout":"branch not found","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01EoC5dUGhb3UmnSe4zseSmL","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"There are no reviews or comments for PR #680. Let me examine the full dispatcher implementation to understand what patterns were used, specifically around the `ToolRegistry` wrapping pattern.\n\nLet me check the context.json if it exists.","signature":"EqoDClsIDRgCKkC08JRlF3N7BzZCZoVFCCkspdbMB01uHQ+9Fwm7bo249hUrYWL5QwRNAOUW6knzMlBYUrajLlr+lK672ksodXuiMhFjbGF1ZGUtc29ubmV0LTQtNjgAEgwn2KFvtZid2VpkB7waDEQB7kwOe08cnvXbSiIw5ZhC75zCSDCBs/VdbwZ/pgc05wzE9MzXsBySUGAKmO3vQhkc1JTR47/xQcoUBLBkKvwBnw7M+2AZ3N+JznuJhz2hdwkBWxLmbGloz3QIVojM7Cy5qlXGsz8zlRBvtr0XQCTSehdSuP6ma54JEc0eZOAPnbOlie2Z4iU/mb6c3rYATDLlyf6HKwLdVdbsdMvkjuyiepm2irJhETsWhc4I2JjZLhWclqubozZGY6omk0L+EmoJaOuDjj8FQN/cSQA5ESKOg7CKNDo1DM5cNYIbpzHsgMqViYfKu3M2PDm8qf3dSRNLSKy5KjH6V/qV1Qc9OQipDT5VDiKVKdGMaHHfZxEC6VDz0t47++tCxYJHiom1OTsnuAS2oY6h/0YNy0THdEcempJU/neiVkTryAyXGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":654,"cache_read_input_tokens":37238,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":654},"output_tokens":30,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"86e87103-9d68-475e-97c3-f471edeba0a2"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01EoC5dUGhb3UmnSe4zseSmL","type":"message","role":"assistant","content":[{"type":"text","text":"Let me look at the remaining dispatcher implementation and the existing MCP dispatcher patterns."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":654,"cache_read_input_tokens":37238,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":654},"output_tokens":30,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"dbc0f8cf-6060-4fb3-a6a5-c4a1f7b66711"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01EoC5dUGhb3UmnSe4zseSmL","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01QfRBYyxMbSDMiCkmAG7Uet","name":"Bash","input":{"command":"gh pr diff 680 2>/dev/null | tail -200","description":"Get remaining PR diff"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":654,"cache_read_input_tokens":37238,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":654},"output_tokens":30,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"bcc6c159-d8ce-4570-b334-b2076e4f5902"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01QfRBYyxMbSDMiCkmAG7Uet","type":"tool_result","content":"+export function decodeBitbucketCheckId(\n+ checkId: string,\n+): { pipelineUuid: string; stepUuid: string } | null {\n+ // UUIDs may be of the form `{...}` which contains no colons — split on\n+ // the first top-level colon that is not inside braces.\n+ let depth = 0;\n+ for (let i = 0; i < checkId.length; i++) {\n+ const ch = checkId[i];\n+ if (ch === '{') depth++;\n+ else if (ch === '}') depth--;\n+ else if (ch === ':' && depth === 0) {\n+ const pipelineUuid = checkId.slice(0, i);\n+ const stepUuid = checkId.slice(i + 1);\n+ if (!pipelineUuid || !stepUuid) return null;\n+ return { pipelineUuid, stepUuid };\n+ }\n+ }\n+ return null;\n+}\n+\n+@Injectable()\n+export class BitbucketContextDispatcher implements OnModuleInit {\n+ private readonly logger = new Logger(BitbucketContextDispatcher.name);\n+\n+ constructor(\n+ private readonly registry: ToolRegistry,\n+ @Inject(BITBUCKET_SERVICE)\n+ private readonly bitbucket: BitbucketService | null,\n+ ) {}\n+\n+ onModuleInit(): void {\n+ // The GitHub dispatcher (SM-1F) registers `v1.check_run_logs` first\n+ // and becomes the default handler. This dispatcher only attaches when\n+ // `repo_ref` is a Bitbucket handle — so we wrap the existing handler\n+ // and route based on platform. When Bitbucket is disabled we skip\n+ // entirely and let the GitHub handler reject BB refs with\n+ // INVALID_INPUT as before.\n+ if (!this.bitbucket) {\n+ this.logger.log(\n+ 'Bitbucket service not configured; skipping BB check_run_logs wiring',\n+ );\n+ return;\n+ }\n+ if (this.registry.has('v1.check_run_logs')) {\n+ const existing = this.registry.get('v1.check_run_logs').handler;\n+ this.registry.replaceHandler(\n+ 'v1.check_run_logs',\n+ (input) => this.routeCheckRunLogs(input, existing),\n+ checkRunLogs.inputSchema,\n+ );\n+ } else {\n+ this.registry.register(\n+ 'v1.check_run_logs',\n+ checkRunLogs.inputSchema,\n+ (input) => this.routeCheckRunLogs(input, undefined),\n+ );\n+ }\n+ }\n+\n+ private async routeCheckRunLogs(\n+ input: unknown,\n+ fallback: ((input: unknown) => unknown) | undefined,\n+ ): Promise<unknown> {\n+ const parsed = input as z.infer<typeof checkRunLogs.inputSchema>;\n+ let platform: string;\n+ try {\n+ platform = parseRepoRef(parsed.repo_ref).platform;\n+ } catch (err) {\n+ if (err instanceof McpError) {\n+ return errorResponse('INVALID_INPUT', err.message);\n+ }\n+ throw err;\n+ }\n+ if (platform === 'bitbucket') {\n+ return this.handleCheckRunLogs(parsed);\n+ }\n+ if (fallback) {\n+ return await Promise.resolve(fallback(input));\n+ }\n+ return errorResponse(\n+ 'INVALID_INPUT',\n+ `repo_ref platform \"${platform}\" not supported by this dispatcher`,\n+ );\n+ }\n+\n+ async handleCheckRunLogs(\n+ input: z.infer<typeof checkRunLogs.inputSchema>,\n+ ): Promise<unknown> {\n+ if (!this.bitbucket) {\n+ return errorResponse('INTERNAL', 'Bitbucket service not configured');\n+ }\n+ const ref = parseRepoRef(input.repo_ref);\n+ if (ref.platform !== 'bitbucket') {\n+ return errorResponse(\n+ 'INVALID_INPUT',\n+ `repo_ref \"${input.repo_ref}\" is not a Bitbucket handle`,\n+ );\n+ }\n+ const decoded = decodeBitbucketCheckId(input.check_id);\n+ if (!decoded) {\n+ return errorResponse(\n+ 'INVALID_INPUT',\n+ `check_id \"${input.check_id}\" is not of the form ` +\n+ `\"<pipeline_uuid>:<step_uuid>\"`,\n+ );\n+ }\n+ const byteLimit = input.max_bytes ?? DEFAULT_LOG_BYTE_LIMIT;\n+\n+ let logs: { text: string; truncated: boolean } | null;\n+ try {\n+ logs = await this.bitbucket.getPipelineStepLog(\n+ ref.workspace,\n+ ref.repo,\n+ decoded.pipelineUuid,\n+ decoded.stepUuid,\n+ byteLimit,\n+ );\n+ } catch (err) {\n+ return this.mapError(err);\n+ }\n+ if (!logs) {\n+ return errorResponse(\n+ 'NOT_FOUND',\n+ `logs for pipeline ${decoded.pipelineUuid} step ${decoded.stepUuid} ` +\n+ `not found in ${ref.workspace}/${ref.repo}`,\n+ );\n+ }\n+\n+ // The BB step-log endpoint is bare text — we don't have a cheap way\n+ // to fetch step name / conclusion / HTML URL here without an extra\n+ // round-trip. SM-3D may add a richer getPipelineStep lookup; for now\n+ // we return placeholders that conform to the output schema.\n+ return {\n+ ok: true,\n+ check_id: input.check_id,\n+ name: `pipeline ${decoded.pipelineUuid} / step ${decoded.stepUuid}`,\n+ conclusion: null,\n+ logs: logs.text,\n+ truncated: logs.truncated,\n+ log_url: null,\n+ };\n+ }\n+\n+ private mapError(err: unknown): ErrorEnvelope {\n+ if (err instanceof BitbucketApiError) {\n+ if (err.status === 404) {\n+ return errorResponse('NOT_FOUND', err.message);\n+ }\n+ if (err.status === 401) {\n+ return errorResponse('UNAUTHENTICATED', err.message);\n+ }\n+ if (err.status === 403) {\n+ return errorResponse('FORBIDDEN', err.message, true);\n+ }\n+ if (err.status === 429) {\n+ return errorResponse('RATE_LIMITED', err.message, true);\n+ }\n+ this.logger.error(`Bitbucket step-log fetch failed: ${err.message}`);\n+ return errorResponse('INTERNAL', err.message);\n+ }\n+ const msg = (err as Error).message ?? String(err);\n+ this.logger.error(`Bitbucket step-log fetch failed: ${msg}`);\n+ return errorResponse('INTERNAL', msg);\n+ }\n+}\ndiff --git a/src/mcp/mcp-subprocess.module.ts b/src/mcp/mcp-subprocess.module.ts\nindex bdde055..80b8d1b 100644\n--- a/src/mcp/mcp-subprocess.module.ts\n+++ b/src/mcp/mcp-subprocess.module.ts\n@@ -3,11 +3,13 @@ import { AppConfigModule } from '../config';\n import { DatabaseModule } from '../database';\n import { EventBusModule } from '../event-bus';\n import { GitHubModule } from '../github';\n+import { BitbucketModule } from '../bitbucket/bitbucket.module';\n import { TaskStateModule } from '../task-state';\n import { TenantModule } from '../tenant/tenant.module';\n import { McpModule } from './mcp.module';\n import { GitHubContextDispatcher } from './dispatchers/github-context.dispatcher';\n import { GitHubIssuesDispatcher } from './dispatchers/github-issues.dispatcher';\n+import { BitbucketContextDispatcher } from './dispatchers/bitbucket-context.dispatcher';\n \n /**\n * Nest module used exclusively by the MCP platform subprocess entrypoint\n@@ -25,10 +27,15 @@ import { GitHubIssuesDispatcher } from './dispatchers/github-issues.dispatcher';\n DatabaseModule,\n EventBusModule,\n GitHubModule,\n+ BitbucketModule,\n TaskStateModule,\n TenantModule,\n McpModule,\n ],\n- providers: [GitHubContextDispatcher, GitHubIssuesDispatcher],\n+ providers: [\n+ GitHubContextDispatcher,\n+ GitHubIssuesDispatcher,\n+ BitbucketContextDispatcher,\n+ ],\n })\n export class McpSubprocessModule {}","is_error":false}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"10788559-3f75-4d1e-b7b3-8d253daf52d9","timestamp":"2026-04-24T17:28:47.718Z","tool_use_result":{"stdout":"+export function decodeBitbucketCheckId(\n+ checkId: string,\n+): { pipelineUuid: string; stepUuid: string } | null {\n+ // UUIDs may be of the form `{...}` which contains no colons — split on\n+ // the first top-level colon that is not inside braces.\n+ let depth = 0;\n+ for (let i = 0; i < checkId.length; i++) {\n+ const ch = checkId[i];\n+ if (ch === '{') depth++;\n+ else if (ch === '}') depth--;\n+ else if (ch === ':' && depth === 0) {\n+ const pipelineUuid = checkId.slice(0, i);\n+ const stepUuid = checkId.slice(i + 1);\n+ if (!pipelineUuid || !stepUuid) return null;\n+ return { pipelineUuid, stepUuid };\n+ }\n+ }\n+ return null;\n+}\n+\n+@Injectable()\n+export class BitbucketContextDispatcher implements OnModuleInit {\n+ private readonly logger = new Logger(BitbucketContextDispatcher.name);\n+\n+ constructor(\n+ private readonly registry: ToolRegistry,\n+ @Inject(BITBUCKET_SERVICE)\n+ private readonly bitbucket: BitbucketService | null,\n+ ) {}\n+\n+ onModuleInit(): void {\n+ // The GitHub dispatcher (SM-1F) registers `v1.check_run_logs` first\n+ // and becomes the default handler. This dispatcher only attaches when\n+ // `repo_ref` is a Bitbucket handle — so we wrap the existing handler\n+ // and route based on platform. When Bitbucket is disabled we skip\n+ // entirely and let the GitHub handler reject BB refs with\n+ // INVALID_INPUT as before.\n+ if (!this.bitbucket) {\n+ this.logger.log(\n+ 'Bitbucket service not configured; skipping BB check_run_logs wiring',\n+ );\n+ return;\n+ }\n+ if (this.registry.has('v1.check_run_logs')) {\n+ const existing = this.registry.get('v1.check_run_logs').handler;\n+ this.registry.replaceHandler(\n+ 'v1.check_run_logs',\n+ (input) => this.routeCheckRunLogs(input, existing),\n+ checkRunLogs.inputSchema,\n+ );\n+ } else {\n+ this.registry.register(\n+ 'v1.check_run_logs',\n+ checkRunLogs.inputSchema,\n+ (input) => this.routeCheckRunLogs(input, undefined),\n+ );\n+ }\n+ }\n+\n+ private async routeCheckRunLogs(\n+ input: unknown,\n+ fallback: ((input: unknown) => unknown) | undefined,\n+ ): Promise<unknown> {\n+ const parsed = input as z.infer<typeof checkRunLogs.inputSchema>;\n+ let platform: string;\n+ try {\n+ platform = parseRepoRef(parsed.repo_ref).platform;\n+ } catch (err) {\n+ if (err instanceof McpError) {\n+ return errorResponse('INVALID_INPUT', err.message);\n+ }\n+ throw err;\n+ }\n+ if (platform === 'bitbucket') {\n+ return this.handleCheckRunLogs(parsed);\n+ }\n+ if (fallback) {\n+ return await Promise.resolve(fallback(input));\n+ }\n+ return errorResponse(\n+ 'INVALID_INPUT',\n+ `repo_ref platform \"${platform}\" not supported by this dispatcher`,\n+ );\n+ }\n+\n+ async handleCheckRunLogs(\n+ input: z.infer<typeof checkRunLogs.inputSchema>,\n+ ): Promise<unknown> {\n+ if (!this.bitbucket) {\n+ return errorResponse('INTERNAL', 'Bitbucket service not configured');\n+ }\n+ const ref = parseRepoRef(input.repo_ref);\n+ if (ref.platform !== 'bitbucket') {\n+ return errorResponse(\n+ 'INVALID_INPUT',\n+ `repo_ref \"${input.repo_ref}\" is not a Bitbucket handle`,\n+ );\n+ }\n+ const decoded = decodeBitbucketCheckId(input.check_id);\n+ if (!decoded) {\n+ return errorResponse(\n+ 'INVALID_INPUT',\n+ `check_id \"${input.check_id}\" is not of the form ` +\n+ `\"<pipeline_uuid>:<step_uuid>\"`,\n+ );\n+ }\n+ const byteLimit = input.max_bytes ?? DEFAULT_LOG_BYTE_LIMIT;\n+\n+ let logs: { text: string; truncated: boolean } | null;\n+ try {\n+ logs = await this.bitbucket.getPipelineStepLog(\n+ ref.workspace,\n+ ref.repo,\n+ decoded.pipelineUuid,\n+ decoded.stepUuid,\n+ byteLimit,\n+ );\n+ } catch (err) {\n+ return this.mapError(err);\n+ }\n+ if (!logs) {\n+ return errorResponse(\n+ 'NOT_FOUND',\n+ `logs for pipeline ${decoded.pipelineUuid} step ${decoded.stepUuid} ` +\n+ `not found in ${ref.workspace}/${ref.repo}`,\n+ );\n+ }\n+\n+ // The BB step-log endpoint is bare text — we don't have a cheap way\n+ // to fetch step name / conclusion / HTML URL here without an extra\n+ // round-trip. SM-3D may add a richer getPipelineStep lookup; for now\n+ // we return placeholders that conform to the output schema.\n+ return {\n+ ok: true,\n+ check_id: input.check_id,\n+ name: `pipeline ${decoded.pipelineUuid} / step ${decoded.stepUuid}`,\n+ conclusion: null,\n+ logs: logs.text,\n+ truncated: logs.truncated,\n+ log_url: null,\n+ };\n+ }\n+\n+ private mapError(err: unknown): ErrorEnvelope {\n+ if (err instanceof BitbucketApiError) {\n+ if (err.status === 404) {\n+ return errorResponse('NOT_FOUND', err.message);\n+ }\n+ if (err.status === 401) {\n+ return errorResponse('UNAUTHENTICATED', err.message);\n+ }\n+ if (err.status === 403) {\n+ return errorResponse('FORBIDDEN', err.message, true);\n+ }\n+ if (err.status === 429) {\n+ return errorResponse('RATE_LIMITED', err.message, true);\n+ }\n+ this.logger.error(`Bitbucket step-log fetch failed: ${err.message}`);\n+ return errorResponse('INTERNAL', err.message);\n+ }\n+ const msg = (err as Error).message ?? String(err);\n+ this.logger.error(`Bitbucket step-log fetch failed: ${msg}`);\n+ return errorResponse('INTERNAL', msg);\n+ }\n+}\ndiff --git a/src/mcp/mcp-subprocess.module.ts b/src/mcp/mcp-subprocess.module.ts\nindex bdde055..80b8d1b 100644\n--- a/src/mcp/mcp-subprocess.module.ts\n+++ b/src/mcp/mcp-subprocess.module.ts\n@@ -3,11 +3,13 @@ import { AppConfigModule } from '../config';\n import { DatabaseModule } from '../database';\n import { EventBusModule } from '../event-bus';\n import { GitHubModule } from '../github';\n+import { BitbucketModule } from '../bitbucket/bitbucket.module';\n import { TaskStateModule } from '../task-state';\n import { TenantModule } from '../tenant/tenant.module';\n import { McpModule } from './mcp.module';\n import { GitHubContextDispatcher } from './dispatchers/github-context.dispatcher';\n import { GitHubIssuesDispatcher } from './dispatchers/github-issues.dispatcher';\n+import { BitbucketContextDispatcher } from './dispatchers/bitbucket-context.dispatcher';\n \n /**\n * Nest module used exclusively by the MCP platform subprocess entrypoint\n@@ -25,10 +27,15 @@ import { GitHubIssuesDispatcher } from './dispatchers/github-issues.dispatcher';\n DatabaseModule,\n EventBusModule,\n GitHubModule,\n+ BitbucketModule,\n TaskStateModule,\n TenantModule,\n McpModule,\n ],\n- providers: [GitHubContextDispatcher, GitHubIssuesDispatcher],\n+ providers: [\n+ GitHubContextDispatcher,\n+ GitHubIssuesDispatcher,\n+ BitbucketContextDispatcher,\n+ ],\n })\n export class McpSubprocessModule {}","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01EoC5dUGhb3UmnSe4zseSmL","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Sb9Rh4Ge8ATsH1bBv5BcHj","name":"Bash","input":{"command":"ls /home/agent/AgentSDE/agent-core/src/mcp/dispatchers/ 2>/dev/null","description":"List MCP dispatchers"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":654,"cache_read_input_tokens":37238,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":654},"output_tokens":30,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"b9aa814e-b4fb-4802-940e-85bc0df11461"}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"Exit code 2","is_error":true,"tool_use_id":"toolu_01Sb9Rh4Ge8ATsH1bBv5BcHj"}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"18ff3238-ba46-4b07-b8f6-c450326a3d6b","timestamp":"2026-04-24T17:28:48.205Z","tool_use_result":"Error: Exit code 2"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01U3d63EAy62ezWLV6dVC85m","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I have a thorough understanding of the implementation. Let me analyze what reusable patterns are worth documenting:\n\n1. **MCP Dispatcher wrapping pattern** (key pattern): When adding a Bitbucket (or any secondary platform) dispatcher for a tool already registered by GitHub, use `registry.has()` + `registry.replaceHandler()` to wrap the existing handler and route by `repo_ref` platform — this allows N platform dispatchers to share one tool name without re-registering.\n\n2. **Bitbucket Pipelines step-log pattern**: `BitbucketService.getPipelineStepLog()` fetches raw `text/plain` bytes and returns `null` on 404; the caller maps to `NOT_FOUND`. \n\n3. **check_id encoding for multi-platform tools**: Bitbucket's `check_id` is encoded as `<pipeline_uuid>:<step_uuid>` with UUID braces preserved. The decoder must split on the first colon NOT inside braces (brace-depth tracking) — a naive `.split(':')` breaks on UUID format like `{abc:def}`.\n\n4. **ToolRegistry conditional wiring with @Optional**: When a service may be absent (`BITBUCKET_SERVICE = null`), guard `onModuleInit()` with a null check to avoid wiring tool handlers that depend on that service.\n\n5. **Error mapping pattern for MCP tools**: Map `BitbucketApiError` status codes consistently: 404 → `NOT_FOUND`, 401 → `UNAUTHENTICATED`, 403 → `FORBIDDEN` (retriable), 429 → `RATE_LIMITED` (retriable), else `INTERNAL` — matching the GitHub dispatcher's approach.\n\nNow I'm checking AGENTS.md to see if there's already documentation on MCP dispatcher routing patterns for multi-platform support, and whether there's an existing section covering SM-* or MCP dispatchers.\n\nLooking at the current AGENTS.md, I don't see explicit entries about MCP dispatcher patterns or ToolRegistry wrapping, so these are genuinely new patterns worth documenting. The most generalizable ones are the MCP dispatcher multi-platform routing (useful for future SM-* tasks where a new platform needs to wrap an existing tool handler using `replaceHandler` and route by platform prefix), the Bitbucket `check_id` encoding with its brace-depth-aware parsing requirement, the optional service guard pattern in `onModuleInit()`, and the consistent error mapping across MCP tools.\n\nFiltering for true generalizability: the `replaceHandler` wrapping pattern is a reusable architectural approach for multi-platform MCP dispatchers, the error envelope convention with consistent `{ ok, code, message, retriable, platform }` structure should be followed across all platform dispatchers, while the Bitbucket-specific `check_id` decoder and the `onModuleInit()` null-guard (which overlaps with existing content) are less critical to document.\n\nI'm settling on three key learnings to add to AGENTS.md: the MCP multi-platform dispatcher wrapping pattern, the error envelope convention with platform-specific code mapping, and the Bitbucket check ID encoding that requires brace-depth tracking rather than naive string splitting.\n\nNow I need to update AGENTS.md carefully—it's currently at 200 lines, so I'll need to consolidate or trim existing content to fit these new patterns without exceeding the limit.\n\nI'm realizing the worktree path is where I should be working, but I need to verify whether the AGENTS.md changes should go into the main repository or stay within this issue-640 worktree context.\n\nLooking at the file size constraint, AGENTS.md is already at 200 lines, so I'll need to consolidate some sections—particularly the SM-* dispatch entries—to make room for the compound-learnings documentation without exceeding the limit.\n\nI'm identifying which verbose sections I can trim. The Database Entities section and some of the detailed bullet points are candidates for compression. I need to free up roughly 10 lines to accommodate the three new learnings: the MCP dispatcher multi-platform wrapping, the MCP error envelope convention, and the Bitbucket check_id encoding.\n\nLet me verify the worktree state first — since it was created for issue #640, the AGENTS.md should match master and won't be affected by PR #680's changes to the service and dispatcher files. Now I'm planning what to add: a new MCP Dispatchers section covering how to register second-platform dispatchers for tools already in the system, along with the error envelope and encoding patterns. I need to export the encoder and decoder helpers for the Bitbucket check ID format, then test them with round-trip validation. When the Bitbucket service isn't configured, the module initialization should gracefully skip tool registration entirely — the GitHub handler stays active while Bitbucket repo refs return an invalid input error, and I need to make sure nothing throws during initialization. I'm realizing I can consolidate some of these verbose entries—combining the `phaseName` default with the phase initialization line, and merging the `phaseKey()` and `setPhaseStatus()` details into a single point about phase state management. Since the file is already at exactly 200 lines, I need to trim about 10 lines to make room for the new MCP Dispatchers section, so this compression approach should work well. I'm looking at the line count and realizing I need to be more aggressive with trimming. The file is already at 200 lines, so I'll need to cut about 9-10 lines to make room for the new MCP Dispatchers section. I can condense the phaseKey notes into a single bullet point, remove some of the verbose database entity descriptions, and fold the \"PR sizing\" header into the surrounding content to free up space. in `createTask()` defaults in `TaskStateService`\n- `onPhaseSkip` / `onPhaseComplete` hooks in `PhaseHooksService` if the phase needs lifecycle handling\n- New skill files under `.claude/skills/<phase>/` (SKILL.md + role.md)\n- `phaseKey()` static map in `TaskStateService` — do NOT rely on dynamic camelCase derivation; it produces `phaseAddress_review` instead of `phaseAddressReview` for underscore-named phases; add an explicit entry to the static map for every new phase\n- `setPhaseStatus(task, phase, status)` only mutates the in-memory entity — it does not persist to DB; to save the change, pass the field explicitly in the `updateTaskStatus()` call (e.g., `{ phaseReview: 'skipped' }`); entity mutation alone is silently discarded and will not survive the next DB read\n\nLines 104-105 are genuinely important warnings. Let me trim the `**PR sizing:**` line and combine some shorter bullets where possible. I'm also adding the MCP Dispatchers section with about 6-7 lines, keeping it compact. For the MCP section, I need to cover platform-specific dispatcher registration using `registry.has()` and `registry.replaceHandler()` to wrap and route by `repo_ref` platform prefix, plus the error response format with HTTP status mapping.\n\nThen I need to handle the Bitbucket `check_id` encoding/decoding with brace-depth tracking to avoid naive split issues, and guard `onModuleInit()` when platform services are null so existing handlers continue to work and BB refs get `INVALID_INPUT` instead of crashing.\n\nNow I'm looking at how to trim the content down further. I merged the PR sizing note into the skills line, which saved one line, but I still need to cut five more. The database entities section has some long bullets that are important, so I'm scanning through other areas to see what can be condensed or removed without losing critical information. I need to extend the phase-event deduplication guard to also skip when the phase status is failed and the task is gated, since persistent blocks that stall differ from transient retries which maintain an active status.\n\nLine 185 is quite long and packs multiple sub-patterns together, but it's genuinely important to keep. I think I'm overcomplicating this—the constraint is under 200 lines and I'm right at 200, so I just need to trim carefully. The `**PR sizing:**` standalone line could merge into the previous line to save one. For the remaining five lines, I'm looking at entries like the GitHubService.findCommentByPrefix call and PhaseHooksService.onPlanComplete that feel overly specific, or older compound-learning entries that might be candidates for removal. I'm reconsidering which entries are actually redundant versus genuinely useful. The injection token reuse is solid general guidance, but the SignalResult field updates and TokenUsageService specifics are too narrow to keep. The GitHub and PhaseHooks references are implementation details that don't belong in architectural guidance. I'll trim those four items to make room for the MCP Dispatchers section.\n\nFor the new MCP content, I'm documenting how to layer platform-specific dispatchers on top of existing tool handlers using registry checks and replacements rather than clobbering registrations, plus the standard error response format with platform-aware HTTP code mappings and retriability flags.\n\nThen I need to handle Bitbucket's composite check IDs that pack pipeline and step UUIDs with braces, requiring careful parsing to avoid splitting on the UUID braces themselves, and guard against null platform services during initialization to cleanly skip registration while letting the fallback handler take over.\n\nThis adds 6 lines, but I've only saved 5 so far — I need to trim one more line somewhere to stay at 200. I'm working through which lines to trim from this document. Line 85 about cancelled E2E CI jobs is worth keeping since it clarifies an important distinction. Line 63 on `PR_BASE_BRANCH` and the async `buildEnv()` method is specific enough to be useful. Line 69 about Claude CLI result events feels too implementation-specific, so I'll cut that along with a few others.\n\nMy plan is to remove five lines total and fold another line to save space, then add a new MCP Dispatchers section to keep the document at 200 lines overall. I need to expand my removals to 7 lines total to make room for the new section. Beyond the initial cuts, I'll also remove the line about reusing upstream injection tokens since it's fairly redundant, along with a few other entries about Claude CLI usage objects and the SignalResult field handling. I'll also fold some lines together and trim out the GitHubService and PhaseHooksService references to hit the target. I'm deciding which overly specific implementation details to trim from the guidance document. Lines 68-71 cover dependency injection token reuse, usage object casing differences, SignalResult field updates, and TokenUsageService write paths—all fairly niche to the codebase. Lines 133 and 135 are similarly granular API pagination and release context specifics. I'll remove these seven lines to keep the guidance focused on generalizable patterns rather than implementation minutiae.\n\nBut I'm second-guessing whether this is the right call. The TU-series work is still active, so stripping those details might leave future developers without important context. Maybe I should be more conservative and add the MCP dispatcher section without cutting existing content, even if it means exceeding 200 lines slightly.\n\nActually, let me try compressing the new MCP section itself instead. I can make it much tighter by combining related points into denser prose rather than separate bullets, which would let me add the content without removing anything else. I'm working through the line count for AGENTS.md — it's currently at exactly 200 lines, so adding the MCP Dispatchers section means I need to trim elsewhere. Let me identify the least critical content to remove: some TU-specific details, a couple RS-specific lines, and folding the PR sizing note into an existing line. That frees up about 7 lines, which gives me room for a concise MCP section with the key patterns.\n\nFor the MCP dispatchers, the critical patterns are: how to wrap shared tools using registry methods and route by platform prefix, the standardized error response format with specific code mappings and retriable flags, and the Bitbucket `check_id` encoding scheme that requires brace-depth tracking to decode correctly.\n\nNow I'm working through the AGENTS.md edits — removing TU-specific implementation details, RS-specific API behaviors, and consolidating the PR sizing point into the previous bullet to bring the document down to 200 lines. The `modelUsage` object is the preferred source for per-model token breakdowns since it includes the detailed camelCase fields, whereas the fallback `usage` object uses snake_case and lacks that granularity. When adding fields to `SignalResult`, I need to update all four resolution paths in `ClaudeInvocationService` to avoid silent gaps in specific outcome types. The `TokenUsageService` has a single write point after each invocation and provides query methods that follow the `MetricsService` pattern with filtering and SQL aggregation.\n\n`GitHubService.findCommentByPrefix()` paginates through issue comments to locate one matching a prefix like `## Agent Plan`, returning its ID and body for editing. When the plan is complete and there's an active release, `PhaseHooksService.onPlanComplete()` appends a `## Release Context` section with human-readable content and collapsed JSON metadata to that comment, or posts a standalone comment if the original isn't found yet due to API lag.\n\nFor MCP dispatchers, I'm using `registry.has()` and `registry.replaceHandler()` to wrap existing handlers and route by platform prefix from the repo reference, falling back to the captured handler for non-matching refs.\n\nContinuing with error handling and Bitbucket specifics...\n\nMCP tool errors follow a standard shape with status codes mapping HTTP responses (404 → `NOT_FOUND`, 401 → `UNAUTHENTICATED`, 403 → `FORBIDDEN` as retriable, 429 → `RATE_LIMITED` as retriable, others → `INTERNAL`). For Bitbucket's `check_id`, I need to encode and decode `{pipeline_uuid}:{step_uuid}` carefully since naive splitting breaks on the UUID braces themselves, so I'm exporting testable helper functions and covering the round-trip in tests.\n\nNow I'm verifying the worktree exists and is accessible at the specified path...","signature":"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"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2656,"cache_read_input_tokens":37892,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2656},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"fd33ea8b-05fa-4ae9-aaca-5522ce063ad7"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01U3d63EAy62ezWLV6dVC85m","type":"message","role":"assistant","content":[{"type":"text","text":"Now I have enough context. Let me verify the worktree and check git status before making changes."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2656,"cache_read_input_tokens":37892,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2656},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"306d5a9c-9343-4b28-a31a-af313532f53a"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01U3d63EAy62ezWLV6dVC85m","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WSDwsTWV8YBRTgeNpJGaCB","name":"Bash","input":{"command":"ls /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/ 2>/dev/null | head -5 && git -C /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md status 2>/dev/null | head -10","description":"Check worktree existence and git status"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2656,"cache_read_input_tokens":37892,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2656},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"ac8e793b-4fcc-4b31-b9a3-fdbe0f62c90a"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WSDwsTWV8YBRTgeNpJGaCB","type":"tool_result","content":"AGENTS.md\nCLAUDE.md\nREADME.md\ndeploy\neslint.config.mjs\nOn branch agents-md/issue-640\nYour branch is up to date with 'origin/master'.\n\nnothing to commit, working tree clean","is_error":false}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"611ad6a1-b69f-4b6d-ace1-eb69883a7707","timestamp":"2026-04-24T17:31:39.155Z","tool_use_result":{"stdout":"AGENTS.md\nCLAUDE.md\nREADME.md\ndeploy\neslint.config.mjs\nOn branch agents-md/issue-640\nYour branch is up to date with 'origin/master'.\n\nnothing to commit, working tree clean","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_015FKYGsQGum3chZ67qdtYQm","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Good, the worktree exists and is on branch `agents-md/issue-640`. Now I need to:\n1. Edit AGENTS.md in the worktree\n2. Commit and push\n3. Create or check existing PR\n\nLet me write the updated AGENTS.md. The key changes are:\n1. Remove lines 69-71 (TU-specific details)\n2. Fold line 103 (PR sizing) into line 102\n3. Remove line 133 (GitHubService.findCommentByPrefix detail)\n4. Remove line 135 (PhaseHooksService.onPlanComplete detail)\n\n\n5. Add MCP Dispatchers section at the end\n\nI'll make these edits with the Edit tool.","signature":"EroFClsIDRgCKkAoINXOrWsQUS43XKyOFOBgWOdM1D1k3YWxbOsBrsKTk3aeympEDpeHlZaNGLm69mO9Ad8IaXL9LlVJofCQDbADMhFjbGF1ZGUtc29ubmV0LTQtNjgAEgyZOEoznUcINDW2cYEaDChyg6j9vbDP4jh5liIwv6qwCWJ79cCsywYW+fDCTwWwnXaBXXNx0LG+iOOC5gk7JYiJnIUBtCeId7RpsEiNKowE9K+etzaR0vpcoddgFMW0f30Gp72SotwRlWrDQN6j8w+TUvupQYTjbS1t+CUDCGG2gFZO24tYTKAOUPFBB4dddrUyngvrCSNZlLLcHCvlCUuROVqMtIgP1iAqJMTJqhSYqw+eY87yPiQ42Uo44AKlaZzmbV7jXwMkCfCzYWbiw0kf4I5/1SIZv8yFzM/5cBefDKINkGOcM+QwbVDiMVCKhQg5R0KLahc+ogzBwkjMooZM83BQGih8MKhv9fPchAqcoV52CxKRUEiPEY3h9UWf4LRnP8LkNFRg7DnNT9bASrwUG91hu2Is4Owj/mqbtmf8SViLDF78awlizDoC+AfAd6QWvZzsK4tzmvt8JhHdCX9OAtIE8GW9MrC0E8fU5gEYjjK3G2mDe8XwWRmDpIMgF/1QAaOyjHndEPp2mkS3Mf+AlGIXPFg+vWLFYWmSrLI/k8SolKntgTnvwtkQVmQWcZ9/ztSGtAsNjLdMujWnyb1hOWjmCVkYhsXMsSzoq4eW1Xc33IZqwfMkCZwF3jo0Q7TilMSoIC9n2osNBB49tL+iS2xhdp1Mroik0qTpDGaBDCOIS9gIyGvGTszfbGET23puVyGMlEsvv6mtqqqFtZR1EJTutgSqWOq84uvyhIwhdKytYIibB6qDKR7GwasjLf+DV9RyxlelBEMVtL+f5LTNQeR/ghRCl6XmAUAYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":9701,"cache_read_input_tokens":40548,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":9701},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"397b88a0-6eac-487b-a9b8-69f30b14f721"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_015FKYGsQGum3chZ67qdtYQm","type":"message","role":"assistant","content":[{"type":"text","text":"The worktree is on `agents-md/issue-640` branch. Now I'll apply the edits to AGENTS.md — removing 6 lines of TU-/RS-specific detail and adding 6 lines for a new MCP Dispatchers section."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":9701,"cache_read_input_tokens":40548,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":9701},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"90a432b3-712a-4ef6-bdfa-1714c801410b"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_015FKYGsQGum3chZ67qdtYQm","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016j4SFNjrZNm7d3DujnrW2Z","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","old_string":"- Claude CLI `result` event has two usage objects with different casing: `modelUsage` (per-model, prefer this) uses camelCase fields (`inputTokens`, `cacheReadInputTokens`, `cacheCreationInputTokens`, `costUSD`); fallback aggregate `usage` uses snake_case (`input_tokens`, `cache_read_input_tokens`). Always prefer `modelUsage` for per-model breakdown.\n- When adding a new field to `SignalResult`, update all four `safeResolve()` call sites in `ClaudeInvocationService` (no-signal, success, error, timeout) — missing any one path causes silent data gaps on that outcome.\n- `TokenUsageService.record(taskId, phase, usage)` is the single write path — called once after each `this.claude.invoke()`, no-ops on `undefined`/empty; query methods (`computeSummary`, `computeByTask`, `computeByPhase`, `computeByProvider`, `computeTimeseries`) follow the `MetricsService` pattern with `sinceExpr` filter, excluded-repos list, and raw SQL aggregation.","new_string":""},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":9701,"cache_read_input_tokens":40548,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":9701},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"e76c06aa-87a3-452a-8472-2be8be0c3ea6"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016j4SFNjrZNm7d3DujnrW2Z","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"5d7f0a83-64c3-43e6-be83-407a82b59ee6","timestamp":"2026-04-24T17:31:50.271Z","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","oldString":"- Claude CLI `result` event has two usage objects with different casing: `modelUsage` (per-model, prefer this) uses camelCase fields (`inputTokens`, `cacheReadInputTokens`, `cacheCreationInputTokens`, `costUSD`); fallback aggregate `usage` uses snake_case (`input_tokens`, `cache_read_input_tokens`). Always prefer `modelUsage` for per-model breakdown.\n- When adding a new field to `SignalResult`, update all four `safeResolve()` call sites in `ClaudeInvocationService` (no-signal, success, error, timeout) — missing any one path causes silent data gaps on that outcome.\n- `TokenUsageService.record(taskId, phase, usage)` is the single write path — called once after each `this.claude.invoke()`, no-ops on `undefined`/empty; query methods (`computeSummary`, `computeByTask`, `computeByPhase`, `computeByProvider`, `computeTimeseries`) follow the `MetricsService` pattern with `sinceExpr` filter, excluded-repos list, and raw SQL aggregation.","newString":"","originalFile":"# Agent Configuration — agent-core\n\n## Purpose\n\nThis is the core orchestration server for the AgentSDE pipeline. It is a production NestJS application. Changes here affect the live webhook handler and all downstream agent runs.\n\n## Skills Available\n\n- `/orchestrate` — used for all issue work (plan + implement + PR)\n- `/review-pr` — used when reviewing PRs on this repo\n\n## Workflow\n\n1. Read the issue carefully — understand the impact on the webhook pipeline\n2. Read the relevant source files before making changes\n3. Run `npm run test` locally to verify unit tests pass\n4. Run `npm run lint` — zero warnings are allowed\n5. Create branch: `feat/<issue-number>-<slug>` or `fix/<issue-number>-<slug>`\n6. Commit with message: `feat(<scope>): <what>` or `fix(<scope>): <what>`\n7. Open PR with `Closes #<issue-number>` in the body\n8. Do not merge your own PR — CI must pass first\n9. When adding a new module, env var, or dev command, update CLAUDE.md in the same PR — `.env.example` is the canonical env vars reference; `src/` modules should all appear in the Key modules list\n10. For documentation-only PRs (no TS/JS modified), steps 3–4 (test and lint) may be skipped — state this explicitly in the pre-commit checks comment\n\n## Task Phases\n\n1. `plan` — read the issue and relevant source files, identify the change\n2. `implement` — make the change, write or update unit tests\n3. `test` — run `npm run test` and `npm run lint`, fix any failures\n4. `review` — open PR, wait for human approval gate\n5. `deploy` — handled by CI on merge to master\n\n## Artefacts\n\nTask state is written to `AgentSDE/agent-core-artefacts` repo, branch `AgentSDE/agent-core/<issue-number>`.\n\n## Directive System\n\n- `ALLOWED_DIRECTIVES` in `directive.dto.ts` is the single source of truth — `DirectiveValue` union type auto-derives from it\n- New directives must be wired in **two places**: `DirectiveService.applyDirective()` (internal API path) and `PhaseRouterService.route()` (GitHub webhook path); missing either creates a silent gap\n- Transient directives (`retry`, `restart`) must **not** overwrite `task.directive` — preserve the task's execution directive (`auto`/`careful`/`quick`)\n- Always post a GitHub feedback comment when a directive is rejected (terminal state, live process, unrecognised) — never drop silently\n- When shipping a new directive or pipeline behavior, add its AGENTS.md documentation in the same PR — deferred documentation accumulates as separate cleanup issues\n- Before closing an open PR on replan/restart, always post a \"superseded\" comment explaining the closure — silent PR closes are flagged by reviewers\n- Each directive that resets task state should have its own dedicated reset method (`resetForReplan`, `resetForRestart`) with distinct semantics — avoid sharing or overloading a single method with conditional branching\n- `DirectiveService.applyDirective()` does not enforce actor-level authorization — that responsibility lives at the GitHub webhook layer. In tests, document this boundary with a `TODO` and a reference issue rather than asserting service-level rejection\n\n### `skip-quality-gate` Directive\n- One-time bypass (`/agentsde skip-quality-gate`, legacy: `/agent skip-quality-gate`) of pre-PR quality gate before deliver phase. Sets `task.qualityGateOverride = true`; consumed atomically at start of next deliver phase (reset to `false` before invocation — cannot carry over). Does not skip code review. Confirmation comment posted on set. **Source:** `directive.service.ts` + `phase-router.service.ts`.\n\n### `refine` Directive\n- Re-runs refine phase on a `gated` task. Rejected with a feedback comment if `task.status` is anything other than `gated` (terminal, active, blocked, paused). **Source:** `directive.service.ts` (`applyDirective`, refine branch).\n\n## Multi-Tenant Consumer Migration (Wave 2+)\n\nWhen migrating any service from `GitHubService` to an abstract provider (e.g., `PlatformProvider`), update three layers atomically: (1) swap `@Inject(PLATFORM_PROVIDER) platform: PlatformProvider` in the constructor (remove `GitHubService` import), (2) replace `GitHubModule` with `PlatformModule` in the module `imports` array, (3) switch the spec mock from `{ provide: GitHubService, useValue: {...} }` to `{ provide: PLATFORM_PROVIDER, useValue: {...} }` and update the retrieved mock type to `jest.Mocked<PlatformProvider>`. Methods typed as optional on the provider interface (e.g., `setProjectItemStatus`) must use `?.()` — this is correct and intentional, not a typing gap.\n\n- PRs that are part of Wave 2+ multi-tenant migration should target `rc/multi-tenant` as the base branch — PRs targeting `master` will be redirected by reviewers. Check whether this branch exists before opening the PR.\n- PRs that are part of the token usage tracking feature series (TU-*) should target `rc/token-usage` as the base branch, not `master` — same pattern as `rc/multi-tenant` for the multi-tenant wave. Token usage types live in `src/llm/llm-provider.interface.ts` (`UsageEntry`, `UsageData`); both `PhaseResult` and `SignalResult` carry `usage?: UsageData`.\n- PRs that are part of the release strategy feature series (RS-*) should target `rc/release-strategy` as the base branch, not `master` — same pattern as `rc/multi-tenant` and `rc/token-usage`; check whether this branch exists before opening the PR.\n- PRs that are part of the GitHub Apps feature series (GA-*) should target `rc/github-apps` as the base branch, not `master` — same pattern as `rc/multi-tenant`, `rc/token-usage`, and `rc/release-strategy`; check whether this branch exists before opening the PR. Same applies to Atlassian integration (BJ-*) → `rc/atlassian-integration`. Same applies to Skills MCP Platform (SM-*) → `rc/skills-mcp-platform`.\n- GitHub App auth: sign RS256 JWTs via Node.js `crypto.createSign('RSA-SHA256')` (no external JWT library); read PEM from disk path (not inline env var); use a promise-based lock so concurrent `getToken()` callers share one refresh promise rather than triggering N parallel API calls; refresh proactively when < 5 min remain on the cached installation token. `GitHubModule` registers a `useFactory` for `PLATFORM_AUTH_PROVIDER` that selects `GitHubAppAuthProvider` vs `GitHubPatAuthProvider` based on `GITHUB_APP_ID` presence, and exports the token — importing `GitHubModule` gives consumers both `GitHubService` and `PLATFORM_AUTH_PROVIDER` without additional setup. Call `await this.authProvider.getToken()` inline per request (not cached at construction) to support token refresh. `getBotUsername()` is synchronous and stable — cache it at construction as `private readonly botUsername` rather than calling per event handler; modules that did not previously import `GitHubModule` must add it to their `imports` to expose `PLATFORM_AUTH_PROVIDER`. When migrating static config vars (e.g., `GITHUB_TOKEN`, `AGENT_USERNAME`) to provider resolution, remove them from `configKeys` in `ClaudeInvocationService` — leftover static entries silently shadow the fresh provider values in the worker env.\n- `PR_BASE_BRANCH` in the Claude invocation env overrides the deliver-phase PR target branch — set by `PhaseRouterService.buildEnv()` via `ReleaseService.findActiveForTask(taskId)`; returns `{ rcBranch, releaseId, baseBranch, key, title, status, directive }` when a task is in an active release, `null` otherwise. `buildEnv()` is async for this reason.\n- When a Wave N dependency module (e.g., `PlatformModule`) is not yet merged, provide a minimal inline provider directly in the consuming module rather than importing the missing module — this unblocks Wave N+1 work without waiting for all dependencies to land; remove the stub once the real module is available.\n- `AtlassianOAuthAuthProvider` follows `GitHubAppAuthProvider`'s 5-min cache + promise concurrency-guard pattern with one critical addition: Atlassian rotates the refresh token on every call — persist via `stateRepo.saveRefreshToken` **before** returning the access token; a crash after refresh but before persistence leaves a consumed token that cannot be retried (permanent re-auth required).\n- BJ-* Atlassian platform modules (`JiraModule`, `BitbucketModule`) each export a platform-specific DI token (`JIRA_AUTH_PROVIDER`, `BITBUCKET_AUTH_PROVIDER`) — NOT the shared `PLATFORM_AUTH_PROVIDER`; root wiring into the generic token is BJ-11. Services that need the unified `PLATFORM_AUTH_PROVIDER` must wait for BJ-11; exception: `InvokeModule` imports platform modules with `@Optional()` and collects non-null providers into `PLATFORM_AUTH_PROVIDERS` (array token) — `ClaudeInvocationService.invoke()` selects by `provider.platform === env['PLATFORM']`, throws for unrecognised platforms, always stamps `env['PLATFORM']` on outgoing env, and does not overwrite caller-supplied values. Env keys per platform: github `{GITHUB_TOKEN, AGENT_USERNAME, GITHUB_OWNER, GITHUB_REPO, PROJECT_ID, PROJECT_NUMBER}`, bitbucket `{BITBUCKET_TOKEN, BITBUCKET_BOT_USERNAME, BITBUCKET_WORKSPACE, BITBUCKET_REPO}`, jira `{JIRA_TOKEN, JIRA_BOT_USERNAME, JIRA_SITE_URL, JIRA_CLOUD_ID, JIRA_PROJECT_KEY}`. Factory providers return `null` when the gateway env var (`*_OAUTH_CLIENT_ID`) is unset — consumers must type-guard before use; app boots cleanly without optional credentials. Define DI tokens in the service file and re-export from the module to prevent circular imports between service and module.\n- Jira REST v3 API conventions: comment/description bodies must be ADF-wrapped (`{ type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] }`); labels mutate via `update: { labels: [{ add: 'lbl' }] }` / `[{ remove: 'lbl' }]` on PUT `/rest/api/3/issue/{key}` — NOT `fields.labels` replacement; `setAssignee` uses a dedicated PUT endpoint `/assignee` with `{ accountId }`.\n- Reuse upstream injection tokens (e.g., `LLM_PROVIDER` from `llm-provider.interface.ts`) rather than defining a local duplicate — token mismatches cause silent DI failures that are hard to diagnose.\n- Claude CLI `result` event has two usage objects with different casing: `modelUsage` (per-model, prefer this) uses camelCase fields (`inputTokens`, `cacheReadInputTokens`, `cacheCreationInputTokens`, `costUSD`); fallback aggregate `usage` uses snake_case (`input_tokens`, `cache_read_input_tokens`). Always prefer `modelUsage` for per-model breakdown.\n- When adding a new field to `SignalResult`, update all four `safeResolve()` call sites in `ClaudeInvocationService` (no-signal, success, error, timeout) — missing any one path causes silent data gaps on that outcome.\n- `TokenUsageService.record(taskId, phase, usage)` is the single write path — called once after each `this.claude.invoke()`, no-ops on `undefined`/empty; query methods (`computeSummary`, `computeByTask`, `computeByPhase`, `computeByProvider`, `computeTimeseries`) follow the `MetricsService` pattern with `sinceExpr` filter, excluded-repos list, and raw SQL aggregation.\n\n## Testing Conventions\n\n- Never hardcode production paths (e.g., `/home/agent/AgentSDE/tasks/...`) in test files — CI machines don't have these directories; use `os.tmpdir()` or a `testTmpDir` fixture instead\n- `ClaudeInvocationService` parses stdout as JSON lines (stream-json format); test stubs must wrap signals in JSON objects, not raw strings\n- When multiple services derive the same filesystem path, centralize it in one helper (e.g., `ArtefactsService.buildCentralTaskDir()`) — prevents path drift across services\n- Prefer real temp dirs over filesystem mocks for I/O-heavy services — mocking `fs` in unit tests for services like `ClaudeInvocationService` raises reviewer concerns and can miss real I/O edge cases\n- When injecting a new shared service into multiple existing modules, update all three layers per consumer: (1) constructor injection in the service, (2) the consumer's module `imports` array (e.g., `TenantModule`), and (3) a mock provider in each `.spec.ts` — reviewers flag any missing layer as a DI breakage risk\n- When pre-existing test failures exist on `master` unrelated to the change, call them out explicitly in the PR body (e.g., \"6 pre-existing watchdog failures unchanged\") — reviewers cannot distinguish regressions from known failures without this\n- When adding a new pipeline status or phase transition, always cover three test scenarios: (1) idempotent re-entry (target state already set), (2) happy path, and (3) recovery path where the triggering condition resolves before the threshold — reviewers flag gaps in these as non-obvious but high-value coverage\n- When testing services that read env vars directly, save and restore original values in `beforeEach`/`afterEach` — unrestored env mutations (e.g., `STUCK_THRESHOLD_MS`, `SLOW_THRESHOLD_MS`) silently corrupt other test suites running in the same Jest worker process\n- For directive integration tests, wire `DirectiveService` with real `TaskStateService` + in-memory SQLite and mocked `GitHubService`/`ArtefactsService` — follow the `blocked-persistent-to-refine` pattern; each new directive spec should cover (1) happy path with open PR, (2) no-open-PR variant, and (3) terminal/active task rejection guard\n- A cancelled E2E CI job (step interrupted by infra timeout, not an assertion failure) is not a code regression — re-trigger E2E after the fix; if the timeout recurs, escalate as a CI infra issue rather than treating it as a test failure\n\n## Logging Conventions\n\n- `{taskDir}/meta/` is the canonical per-task log directory — already included in `ARTEFACT_SUBDIRS`; files written there are synced to `agent-core-artefacts` automatically without further config changes\n- When piping spawned-process stdout/stderr to a file write stream, open in append mode (`flags: 'a'`) and close the stream in both the `close` and `error` event handlers to avoid resource leaks\n- In cleanup methods that `unlink` files in a loop, only swallow `ENOENT` errors; re-throw or log all other errors — broad `catch` blocks in cleanup paths are reviewers' top flag for silent data-loss risk\n\n## Adding a New Pipeline Phase\n\nWhen adding a new SDLC phase, all of the following must be updated in the same PR:\n- `PHASE_ORDER` array and `phaseColumn()` map in `InternalAdapterService`\n- `PHASE_TO_SKILL` map in `ClaudeInvocationService`\n- New `phase<Name>` column on `TaskEntity` (with `DEFAULT 'pending'` for safe migration)\n- `phaseName: 'pending'` in `createTask()` defaults in `TaskStateService`\n- `onPhaseSkip` / `onPhaseComplete` hooks in `PhaseHooksService` if the phase needs lifecycle handling\n- New skill files under `.claude/skills/<phase>/` (SKILL.md + role.md)\n**PR sizing:** Even though all these files change together, consider splitting DB schema changes (migration + entity) into a separate PR from routing and skill logic — reviewers flag large pipeline changes as high-risk.\n- `phaseKey()` static map in `TaskStateService` — do NOT rely on dynamic camelCase derivation; it produces `phaseAddress_review` instead of `phaseAddressReview` for underscore-named phases; add an explicit entry to the static map for every new phase\n- `setPhaseStatus(task, phase, status)` only mutates the in-memory entity — it does not persist to DB; to save the change, pass the field explicitly in the `updateTaskStatus()` call (e.g., `{ phaseReview: 'skipped' }`); entity mutation alone is silently discarded and will not survive the next DB read\n\n## Worktree Management\n\n- Use idempotent `getOrCreateWorktree()` instead of per-phase create/destroy: check if worktree is registered via `git worktree list --porcelain`, attach an existing branch without `-b`, or fresh-create — avoids `git branch -D` failures on checked-out branches that cause permanent stuck loops\n- Move worktree cleanup to terminal state (`complete`) in `InternalAdapterService`, not in `finally` blocks inside `PhaseRouterService` — per-phase cleanup destroys worktrees still needed by subsequent phases\n- Tasks that never reach `complete` (permanently blocked or abandoned) accumulate worktrees indefinitely — any cancellation or watchdog path that abandons a task must also call `cleanupWorktree()`\n- On worktree reuse, run only `git fetch origin` to update remote-tracking refs — never `git reset --hard origin/master`; the branch already holds agent commits from prior phases, and a hard reset silently destroys them\n\n## Gate & Merge\n\n- Use an explicit `GATE_BYPASS_EVENT_TYPES = new Set(['directive', 'review_approved', 'ci_passed', 'ci_failure'])` in `PhaseRouterService` — replacing a bare `event.type !== 'directive'` check prevents `review_approved` and CI events from re-gating an already-approved task\n- When a `review_approved` event arrives and the deliver phase is already complete, call `mergeIfReady()` directly — extract merge logic into a shared public method so both `onDeliverComplete` and the `review_approved` path reuse it without duplication\n- Every phase-completion code path must call `phaseHooks.onPhaseComplete(task, phase)` explicitly — hooks inside `executePhase` do not fire on alternative paths (e.g., `review_approved` clearing the gate); a missing call silently skips lifecycle effects such as auto-merge even when the phase status is correctly written\n\n## Release Strategy\n\nA release is a configuration override that groups tasks and routes their PRs to an RC branch instead of master.\n\n### Data Model\n\n3 tables: `releases`, `release_tasks`, `release_repo_prs`.\n\n### Pipeline Integration\n\nWhen a task belongs to an active release:\n- `PhaseRouterService.buildEnv()` sets `PR_BASE_BRANCH` to the RC branch\n- The deliver skill creates PRs targeting the RC branch (not master)\n- `findActiveForTask(taskId)` is the key lookup — queries `release_tasks` → `releases`\n\n- When a task belongs to an active release, pass `rcBranch` as `baseBranch` to `getOrCreateWorktree()` (default `'master'`) — omitting it creates worktrees from master, not the RC branch; fetch the release config once in `executePhase()` and thread it through to both `getOrCreateWorktree()` and `buildEnv()` to avoid a duplicate `findActiveForTask()` call.\n- `GitHubService.findCommentByPrefix(owner, repo, issueNumber, prefix)` paginates issue comments (`per_page=100`) and returns `{ id, body } | null` — use it to locate a specific comment (e.g. `## Agent Plan`) before editing it.\n- `PhaseHooksService.onPlanComplete()` appends a `## Release Context` section (human-readable + collapsed JSON metadata) to the existing plan comment when the task has an active release; falls back to posting a standalone comment if the plan comment is not found (handles GitHub API propagation lag).\n\n### Status Lifecycle\n\n`draft` → `active` → `staging` → `validating` → `validated` → `shipping` → `shipped`\n(`failed` can transition back to `active` for retry)\n\n## Watchdog\n\n- Query `['active', 'gated']` statuses in `WatchdogService` — tasks waiting indefinitely for deliver-gate approval are invisible with `active`-only queries and accumulate as silent stuck issues\n- Expose `POST /internal/watchdog/run` (returns `{ stuck, slow }`) for on-demand detection — avoids E2E tests waiting 5 minutes for the cron cycle; `runCheck()` should be a public method on the service\n\n## Test Environment\n\nA test instance of agent-core runs alongside production for E2E verification before merging to master.\n\n- **Test clone:** `/home/agent/AgentSDE/testing/agent-core` (branch: `testing`)\n- **Port:** 8777, managed by pm2 as `agent-core-test`\n- **Database:** `agent-core-test.db` (configured via `DB_NAME` env var, separate from production `agent-core.db`)\n- **Nginx:** `wh.kontashera.com/test/*` proxies to `localhost:8777`\n- **Webhook:** Repo-level webhook on `AgentSDE/agent-core-e2e` delivers to `wh.kontashera.com/test/webhooks/github`\n- Never modify the test instance while production E2E or nightly runs are in progress\n- Always restore `agent-core-e2e/.env` `AGENT_CORE_URL` back to `http://localhost:3000` after testing\n- The `testing` branch can be force-pushed freely — it is not protected\n\n## Database Entities\n\nWhen adding a new TypeORM entity, update four locations atomically:\n1. Create entity file under `src/database/entities/<name>.entity.ts`\n2. Export from `src/database/entities/index.ts` barrel\n3. Add to the `forRoot` entities array in `DatabaseModule`\n4. Add to the `forFeature` array in `DatabaseModule`\n\nFor SQLite: use `type: 'integer'`/`type: 'real'` for numeric columns (TypeORM defaults don't map correctly); use `@Column({ name: 'snake_case_name' })` for consistent snake_case DB column naming.\n- When a service needs entities from another domain (e.g., `TaskEntity` in `ReleaseModule`), add them to the local `TypeOrmModule.forFeature()` array — do NOT import the owning module; avoids circular dependency risks.\n- When removing a TypeORM entity, mirror the four-location add pattern in reverse: delete the entity file, remove its export from `entities/index.ts`, remove it from the `forRoot` entities array in `DatabaseModule`, and remove `forFeature` references in all consumer modules (e.g., `OperationalModule`, `ControlApiModule`) and their spec files.\n- Before inserting into a join/relation table, always query existing rows and filter them out — silently inserting duplicates breaks unique constraints and is hard to diagnose at runtime.\n- When an issue specifies Postgres-style schema changes (migration files, `uuid_generate_v4()`, `timestamptz`), translate to a TypeORM entity with SQLite-compatible types (`@PrimaryGeneratedColumn('uuid')`, `datetime`) — no migration tooling exists in this repo; `synchronize: true` is the schema mechanism. Verify the schema in `database.module.spec.ts` with in-memory SQLite covering uuid generation, unique-constraint enforcement, and cross-tenant reuse.\n\n## REST Controllers\n\n- Throw HTTP exceptions (`NotFoundException`, etc.) in the controller layer, not in the service — services should return `null`/`undefined` for \"not found\" cases; the controller converts these to the appropriate HTTP status using NestJS built-in exceptions.\n- Register new controllers in their owning module and import that module into `AppModule` in the same PR — a controller not imported into `AppModule` is silently unreachable.\n\n## Redis / BullMQ\n\n- Use a dedicated `ioredis` instance (`lazyConnect: true`) in `HealthModule` for `/health` Redis probes — never share BullMQ's internal connection; health checks coupled to queue lifecycle report misleading states during degraded scenarios\n- Register queues via `BullModule.forRootAsync()` (ConfigService-driven) + `BullModule.registerQueue()` in `AppModule` — this is the established queue registration pattern\n- Redis pub/sub requires two separate ioredis clients — one dedicated to publish (`pub`), one to subscribe (`sub`); a client in subscribe mode cannot also publish. Follow the `EventBusModule` factory pattern. `emit()` must swallow Redis errors; per-handler error isolation in `on()` prevents one failing handler from blocking others — event delivery must never block pipeline advancement\n- If the BullMQ class manages its own `IORedis` connections directly, do NOT also register `BullModule.forRootAsync` in the same feature module — it conflicts with `InvokeModule`'s existing registration; use direct `IORedis` instances instead.\n- **BullMQ stall-retry idempotency**: Wrap `createTask()` in `route()` with try/catch; on unique-constraint error fall back to `findByIssueAndRepo()` — concurrent deliveries share the existing row. Guard phase hooks (`onPhaseBlocked`, `onCompoundComplete`) with per-phase event records before posting comments; skip if already recorded. Add early-return in `executePhase()` if phase is already `'active'`. Extend phase-event dedup guard to skip when `phaseStatus === 'failed' && task.status === 'gated'` — persistent-block stalls differ from transient retries (which keep `status === 'active'`).\n\n## Config Schema\n\n- Use Joi `.when()` for conditional env var validation in two directions: (a) make existing required fields optional when a new auth mode flag is present (`Joi.string().when('FLAG', { is: Joi.exist(), then: Joi.optional(), otherwise: Joi.required() })`); (b) make companion vars required when an optional provider's gateway var is set (e.g., `*_OAUTH_CLIENT_ID`). Designate one gateway var per provider group — partial configs missing the gateway silently pass (intentional; mirrors `GITHUB_APP_ID` pattern).\n- Config schema tests for conditional validators must cover three cases: (1) empty/legacy config passes with no error, (2) gateway set with all required companions passes, (3) gateway set but companions missing fails and lists the missing vars — case (3) is the partial-fail guard that prevents silent misconfiguration.\n\n## Important constraints\n\n- This service runs live on the VPS via pm2 (`pm2 restart agent-core` to reload)\n- Port `3000` is proxied through nginx — do not change the port without updating nginx config\n- `GITHUB_WEBHOOK_SECRET` must match the secret configured in the GitHub org webhook\n- The WAL file path (`agent-core-artefacts/watchdog/wal.jsonl`) is shared with the watchdog — do not change its location without updating both services\n- `ClaudeInvocationService.invoke()` is now the primary entry point for agent runs; `dispatch-v2.sh`/`DispatchService` is legacy and being superseded by the `invoke` module\n- `GITHUB_WEBHOOK_SECRET` must be read with `get()` (not `getOrThrow()`) and verification skipped when unset — E2E environments deliberately omit the secret; `getOrThrow()` returns HTTP 500 instead of gracefully ignoring the event\n- `TASK_DIR` is resolved by `ArtefactsService.buildCentralTaskDir()` and set in the Claude invocation env by `PhaseRouterService` — do not construct it independently in other services\n","structuredPatch":[{"oldStart":66,"oldLines":9,"newStart":66,"newLines":6,"lines":[" - BJ-* Atlassian platform modules (`JiraModule`, `BitbucketModule`) each export a platform-specific DI token (`JIRA_AUTH_PROVIDER`, `BITBUCKET_AUTH_PROVIDER`) — NOT the shared `PLATFORM_AUTH_PROVIDER`; root wiring into the generic token is BJ-11. Services that need the unified `PLATFORM_AUTH_PROVIDER` must wait for BJ-11; exception: `InvokeModule` imports platform modules with `@Optional()` and collects non-null providers into `PLATFORM_AUTH_PROVIDERS` (array token) — `ClaudeInvocationService.invoke()` selects by `provider.platform === env['PLATFORM']`, throws for unrecognised platforms, always stamps `env['PLATFORM']` on outgoing env, and does not overwrite caller-supplied values. Env keys per platform: github `{GITHUB_TOKEN, AGENT_USERNAME, GITHUB_OWNER, GITHUB_REPO, PROJECT_ID, PROJECT_NUMBER}`, bitbucket `{BITBUCKET_TOKEN, BITBUCKET_BOT_USERNAME, BITBUCKET_WORKSPACE, BITBUCKET_REPO}`, jira `{JIRA_TOKEN, JIRA_BOT_USERNAME, JIRA_SITE_URL, JIRA_CLOUD_ID, JIRA_PROJECT_KEY}`. Factory providers return `null` when the gateway env var (`*_OAUTH_CLIENT_ID`) is unset — consumers must type-guard before use; app boots cleanly without optional credentials. Define DI tokens in the service file and re-export from the module to prevent circular imports between service and module."," - Jira REST v3 API conventions: comment/description bodies must be ADF-wrapped (`{ type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] }`); labels mutate via `update: { labels: [{ add: 'lbl' }] }` / `[{ remove: 'lbl' }]` on PUT `/rest/api/3/issue/{key}` — NOT `fields.labels` replacement; `setAssignee` uses a dedicated PUT endpoint `/assignee` with `{ accountId }`."," - Reuse upstream injection tokens (e.g., `LLM_PROVIDER` from `llm-provider.interface.ts`) rather than defining a local duplicate — token mismatches cause silent DI failures that are hard to diagnose.","-- Claude CLI `result` event has two usage objects with different casing: `modelUsage` (per-model, prefer this) uses camelCase fields (`inputTokens`, `cacheReadInputTokens`, `cacheCreationInputTokens`, `costUSD`); fallback aggregate `usage` uses snake_case (`input_tokens`, `cache_read_input_tokens`). Always prefer `modelUsage` for per-model breakdown.","-- When adding a new field to `SignalResult`, update all four `safeResolve()` call sites in `ClaudeInvocationService` (no-signal, success, error, timeout) — missing any one path causes silent data gaps on that outcome.","-- `TokenUsageService.record(taskId, phase, usage)` is the single write path — called once after each `this.claude.invoke()`, no-ops on `undefined`/empty; query methods (`computeSummary`, `computeByTask`, `computeByPhase`, `computeByProvider`, `computeTimeseries`) follow the `MetricsService` pattern with `sinceExpr` filter, excluded-repos list, and raw SQL aggregation."," "," ## Testing Conventions"," "]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01RuiiECAinqHhrctvWHGyLq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DRFDCDLxu9tvVWbBe1f1i3","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","old_string":"- New skill files under `.claude/skills/<phase>/` (SKILL.md + role.md)\n**PR sizing:** Even though all these files change together, consider splitting DB schema changes (migration + entity) into a separate PR from routing and skill logic — reviewers flag large pipeline changes as high-risk.","new_string":"- New skill files under `.claude/skills/<phase>/` (SKILL.md + role.md); consider splitting DB schema changes into a separate PR — reviewers flag large pipeline additions as high-risk"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":807,"cache_read_input_tokens":50249,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":807},"output_tokens":62,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"d34524a7-32bc-4c2b-a5a1-a921bf7ea51c"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DRFDCDLxu9tvVWbBe1f1i3","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"04bb2898-83d7-4a50-91a6-aadd9211e945","timestamp":"2026-04-24T17:31:54.846Z","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","oldString":"- New skill files under `.claude/skills/<phase>/` (SKILL.md + role.md)\n**PR sizing:** Even though all these files change together, consider splitting DB schema changes (migration + entity) into a separate PR from routing and skill logic — reviewers flag large pipeline changes as high-risk.","newString":"- New skill files under `.claude/skills/<phase>/` (SKILL.md + role.md); consider splitting DB schema changes into a separate PR — reviewers flag large pipeline additions as high-risk","originalFile":"# Agent Configuration — agent-core\n\n## Purpose\n\nThis is the core orchestration server for the AgentSDE pipeline. It is a production NestJS application. Changes here affect the live webhook handler and all downstream agent runs.\n\n## Skills Available\n\n- `/orchestrate` — used for all issue work (plan + implement + PR)\n- `/review-pr` — used when reviewing PRs on this repo\n\n## Workflow\n\n1. Read the issue carefully — understand the impact on the webhook pipeline\n2. Read the relevant source files before making changes\n3. Run `npm run test` locally to verify unit tests pass\n4. Run `npm run lint` — zero warnings are allowed\n5. Create branch: `feat/<issue-number>-<slug>` or `fix/<issue-number>-<slug>`\n6. Commit with message: `feat(<scope>): <what>` or `fix(<scope>): <what>`\n7. Open PR with `Closes #<issue-number>` in the body\n8. Do not merge your own PR — CI must pass first\n9. When adding a new module, env var, or dev command, update CLAUDE.md in the same PR — `.env.example` is the canonical env vars reference; `src/` modules should all appear in the Key modules list\n10. For documentation-only PRs (no TS/JS modified), steps 3–4 (test and lint) may be skipped — state this explicitly in the pre-commit checks comment\n\n## Task Phases\n\n1. `plan` — read the issue and relevant source files, identify the change\n2. `implement` — make the change, write or update unit tests\n3. `test` — run `npm run test` and `npm run lint`, fix any failures\n4. `review` — open PR, wait for human approval gate\n5. `deploy` — handled by CI on merge to master\n\n## Artefacts\n\nTask state is written to `AgentSDE/agent-core-artefacts` repo, branch `AgentSDE/agent-core/<issue-number>`.\n\n## Directive System\n\n- `ALLOWED_DIRECTIVES` in `directive.dto.ts` is the single source of truth — `DirectiveValue` union type auto-derives from it\n- New directives must be wired in **two places**: `DirectiveService.applyDirective()` (internal API path) and `PhaseRouterService.route()` (GitHub webhook path); missing either creates a silent gap\n- Transient directives (`retry`, `restart`) must **not** overwrite `task.directive` — preserve the task's execution directive (`auto`/`careful`/`quick`)\n- Always post a GitHub feedback comment when a directive is rejected (terminal state, live process, unrecognised) — never drop silently\n- When shipping a new directive or pipeline behavior, add its AGENTS.md documentation in the same PR — deferred documentation accumulates as separate cleanup issues\n- Before closing an open PR on replan/restart, always post a \"superseded\" comment explaining the closure — silent PR closes are flagged by reviewers\n- Each directive that resets task state should have its own dedicated reset method (`resetForReplan`, `resetForRestart`) with distinct semantics — avoid sharing or overloading a single method with conditional branching\n- `DirectiveService.applyDirective()` does not enforce actor-level authorization — that responsibility lives at the GitHub webhook layer. In tests, document this boundary with a `TODO` and a reference issue rather than asserting service-level rejection\n\n### `skip-quality-gate` Directive\n- One-time bypass (`/agentsde skip-quality-gate`, legacy: `/agent skip-quality-gate`) of pre-PR quality gate before deliver phase. Sets `task.qualityGateOverride = true`; consumed atomically at start of next deliver phase (reset to `false` before invocation — cannot carry over). Does not skip code review. Confirmation comment posted on set. **Source:** `directive.service.ts` + `phase-router.service.ts`.\n\n### `refine` Directive\n- Re-runs refine phase on a `gated` task. Rejected with a feedback comment if `task.status` is anything other than `gated` (terminal, active, blocked, paused). **Source:** `directive.service.ts` (`applyDirective`, refine branch).\n\n## Multi-Tenant Consumer Migration (Wave 2+)\n\nWhen migrating any service from `GitHubService` to an abstract provider (e.g., `PlatformProvider`), update three layers atomically: (1) swap `@Inject(PLATFORM_PROVIDER) platform: PlatformProvider` in the constructor (remove `GitHubService` import), (2) replace `GitHubModule` with `PlatformModule` in the module `imports` array, (3) switch the spec mock from `{ provide: GitHubService, useValue: {...} }` to `{ provide: PLATFORM_PROVIDER, useValue: {...} }` and update the retrieved mock type to `jest.Mocked<PlatformProvider>`. Methods typed as optional on the provider interface (e.g., `setProjectItemStatus`) must use `?.()` — this is correct and intentional, not a typing gap.\n\n- PRs that are part of Wave 2+ multi-tenant migration should target `rc/multi-tenant` as the base branch — PRs targeting `master` will be redirected by reviewers. Check whether this branch exists before opening the PR.\n- PRs that are part of the token usage tracking feature series (TU-*) should target `rc/token-usage` as the base branch, not `master` — same pattern as `rc/multi-tenant` for the multi-tenant wave. Token usage types live in `src/llm/llm-provider.interface.ts` (`UsageEntry`, `UsageData`); both `PhaseResult` and `SignalResult` carry `usage?: UsageData`.\n- PRs that are part of the release strategy feature series (RS-*) should target `rc/release-strategy` as the base branch, not `master` — same pattern as `rc/multi-tenant` and `rc/token-usage`; check whether this branch exists before opening the PR.\n- PRs that are part of the GitHub Apps feature series (GA-*) should target `rc/github-apps` as the base branch, not `master` — same pattern as `rc/multi-tenant`, `rc/token-usage`, and `rc/release-strategy`; check whether this branch exists before opening the PR. Same applies to Atlassian integration (BJ-*) → `rc/atlassian-integration`. Same applies to Skills MCP Platform (SM-*) → `rc/skills-mcp-platform`.\n- GitHub App auth: sign RS256 JWTs via Node.js `crypto.createSign('RSA-SHA256')` (no external JWT library); read PEM from disk path (not inline env var); use a promise-based lock so concurrent `getToken()` callers share one refresh promise rather than triggering N parallel API calls; refresh proactively when < 5 min remain on the cached installation token. `GitHubModule` registers a `useFactory` for `PLATFORM_AUTH_PROVIDER` that selects `GitHubAppAuthProvider` vs `GitHubPatAuthProvider` based on `GITHUB_APP_ID` presence, and exports the token — importing `GitHubModule` gives consumers both `GitHubService` and `PLATFORM_AUTH_PROVIDER` without additional setup. Call `await this.authProvider.getToken()` inline per request (not cached at construction) to support token refresh. `getBotUsername()` is synchronous and stable — cache it at construction as `private readonly botUsername` rather than calling per event handler; modules that did not previously import `GitHubModule` must add it to their `imports` to expose `PLATFORM_AUTH_PROVIDER`. When migrating static config vars (e.g., `GITHUB_TOKEN`, `AGENT_USERNAME`) to provider resolution, remove them from `configKeys` in `ClaudeInvocationService` — leftover static entries silently shadow the fresh provider values in the worker env.\n- `PR_BASE_BRANCH` in the Claude invocation env overrides the deliver-phase PR target branch — set by `PhaseRouterService.buildEnv()` via `ReleaseService.findActiveForTask(taskId)`; returns `{ rcBranch, releaseId, baseBranch, key, title, status, directive }` when a task is in an active release, `null` otherwise. `buildEnv()` is async for this reason.\n- When a Wave N dependency module (e.g., `PlatformModule`) is not yet merged, provide a minimal inline provider directly in the consuming module rather than importing the missing module — this unblocks Wave N+1 work without waiting for all dependencies to land; remove the stub once the real module is available.\n- `AtlassianOAuthAuthProvider` follows `GitHubAppAuthProvider`'s 5-min cache + promise concurrency-guard pattern with one critical addition: Atlassian rotates the refresh token on every call — persist via `stateRepo.saveRefreshToken` **before** returning the access token; a crash after refresh but before persistence leaves a consumed token that cannot be retried (permanent re-auth required).\n- BJ-* Atlassian platform modules (`JiraModule`, `BitbucketModule`) each export a platform-specific DI token (`JIRA_AUTH_PROVIDER`, `BITBUCKET_AUTH_PROVIDER`) — NOT the shared `PLATFORM_AUTH_PROVIDER`; root wiring into the generic token is BJ-11. Services that need the unified `PLATFORM_AUTH_PROVIDER` must wait for BJ-11; exception: `InvokeModule` imports platform modules with `@Optional()` and collects non-null providers into `PLATFORM_AUTH_PROVIDERS` (array token) — `ClaudeInvocationService.invoke()` selects by `provider.platform === env['PLATFORM']`, throws for unrecognised platforms, always stamps `env['PLATFORM']` on outgoing env, and does not overwrite caller-supplied values. Env keys per platform: github `{GITHUB_TOKEN, AGENT_USERNAME, GITHUB_OWNER, GITHUB_REPO, PROJECT_ID, PROJECT_NUMBER}`, bitbucket `{BITBUCKET_TOKEN, BITBUCKET_BOT_USERNAME, BITBUCKET_WORKSPACE, BITBUCKET_REPO}`, jira `{JIRA_TOKEN, JIRA_BOT_USERNAME, JIRA_SITE_URL, JIRA_CLOUD_ID, JIRA_PROJECT_KEY}`. Factory providers return `null` when the gateway env var (`*_OAUTH_CLIENT_ID`) is unset — consumers must type-guard before use; app boots cleanly without optional credentials. Define DI tokens in the service file and re-export from the module to prevent circular imports between service and module.\n- Jira REST v3 API conventions: comment/description bodies must be ADF-wrapped (`{ type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] }`); labels mutate via `update: { labels: [{ add: 'lbl' }] }` / `[{ remove: 'lbl' }]` on PUT `/rest/api/3/issue/{key}` — NOT `fields.labels` replacement; `setAssignee` uses a dedicated PUT endpoint `/assignee` with `{ accountId }`.\n- Reuse upstream injection tokens (e.g., `LLM_PROVIDER` from `llm-provider.interface.ts`) rather than defining a local duplicate — token mismatches cause silent DI failures that are hard to diagnose.\n\n## Testing Conventions\n\n- Never hardcode production paths (e.g., `/home/agent/AgentSDE/tasks/...`) in test files — CI machines don't have these directories; use `os.tmpdir()` or a `testTmpDir` fixture instead\n- `ClaudeInvocationService` parses stdout as JSON lines (stream-json format); test stubs must wrap signals in JSON objects, not raw strings\n- When multiple services derive the same filesystem path, centralize it in one helper (e.g., `ArtefactsService.buildCentralTaskDir()`) — prevents path drift across services\n- Prefer real temp dirs over filesystem mocks for I/O-heavy services — mocking `fs` in unit tests for services like `ClaudeInvocationService` raises reviewer concerns and can miss real I/O edge cases\n- When injecting a new shared service into multiple existing modules, update all three layers per consumer: (1) constructor injection in the service, (2) the consumer's module `imports` array (e.g., `TenantModule`), and (3) a mock provider in each `.spec.ts` — reviewers flag any missing layer as a DI breakage risk\n- When pre-existing test failures exist on `master` unrelated to the change, call them out explicitly in the PR body (e.g., \"6 pre-existing watchdog failures unchanged\") — reviewers cannot distinguish regressions from known failures without this\n- When adding a new pipeline status or phase transition, always cover three test scenarios: (1) idempotent re-entry (target state already set), (2) happy path, and (3) recovery path where the triggering condition resolves before the threshold — reviewers flag gaps in these as non-obvious but high-value coverage\n- When testing services that read env vars directly, save and restore original values in `beforeEach`/`afterEach` — unrestored env mutations (e.g., `STUCK_THRESHOLD_MS`, `SLOW_THRESHOLD_MS`) silently corrupt other test suites running in the same Jest worker process\n- For directive integration tests, wire `DirectiveService` with real `TaskStateService` + in-memory SQLite and mocked `GitHubService`/`ArtefactsService` — follow the `blocked-persistent-to-refine` pattern; each new directive spec should cover (1) happy path with open PR, (2) no-open-PR variant, and (3) terminal/active task rejection guard\n- A cancelled E2E CI job (step interrupted by infra timeout, not an assertion failure) is not a code regression — re-trigger E2E after the fix; if the timeout recurs, escalate as a CI infra issue rather than treating it as a test failure\n\n## Logging Conventions\n\n- `{taskDir}/meta/` is the canonical per-task log directory — already included in `ARTEFACT_SUBDIRS`; files written there are synced to `agent-core-artefacts` automatically without further config changes\n- When piping spawned-process stdout/stderr to a file write stream, open in append mode (`flags: 'a'`) and close the stream in both the `close` and `error` event handlers to avoid resource leaks\n- In cleanup methods that `unlink` files in a loop, only swallow `ENOENT` errors; re-throw or log all other errors — broad `catch` blocks in cleanup paths are reviewers' top flag for silent data-loss risk\n\n## Adding a New Pipeline Phase\n\nWhen adding a new SDLC phase, all of the following must be updated in the same PR:\n- `PHASE_ORDER` array and `phaseColumn()` map in `InternalAdapterService`\n- `PHASE_TO_SKILL` map in `ClaudeInvocationService`\n- New `phase<Name>` column on `TaskEntity` (with `DEFAULT 'pending'` for safe migration)\n- `phaseName: 'pending'` in `createTask()` defaults in `TaskStateService`\n- `onPhaseSkip` / `onPhaseComplete` hooks in `PhaseHooksService` if the phase needs lifecycle handling\n- New skill files under `.claude/skills/<phase>/` (SKILL.md + role.md)\n**PR sizing:** Even though all these files change together, consider splitting DB schema changes (migration + entity) into a separate PR from routing and skill logic — reviewers flag large pipeline changes as high-risk.\n- `phaseKey()` static map in `TaskStateService` — do NOT rely on dynamic camelCase derivation; it produces `phaseAddress_review` instead of `phaseAddressReview` for underscore-named phases; add an explicit entry to the static map for every new phase\n- `setPhaseStatus(task, phase, status)` only mutates the in-memory entity — it does not persist to DB; to save the change, pass the field explicitly in the `updateTaskStatus()` call (e.g., `{ phaseReview: 'skipped' }`); entity mutation alone is silently discarded and will not survive the next DB read\n\n## Worktree Management\n\n- Use idempotent `getOrCreateWorktree()` instead of per-phase create/destroy: check if worktree is registered via `git worktree list --porcelain`, attach an existing branch without `-b`, or fresh-create — avoids `git branch -D` failures on checked-out branches that cause permanent stuck loops\n- Move worktree cleanup to terminal state (`complete`) in `InternalAdapterService`, not in `finally` blocks inside `PhaseRouterService` — per-phase cleanup destroys worktrees still needed by subsequent phases\n- Tasks that never reach `complete` (permanently blocked or abandoned) accumulate worktrees indefinitely — any cancellation or watchdog path that abandons a task must also call `cleanupWorktree()`\n- On worktree reuse, run only `git fetch origin` to update remote-tracking refs — never `git reset --hard origin/master`; the branch already holds agent commits from prior phases, and a hard reset silently destroys them\n\n## Gate & Merge\n\n- Use an explicit `GATE_BYPASS_EVENT_TYPES = new Set(['directive', 'review_approved', 'ci_passed', 'ci_failure'])` in `PhaseRouterService` — replacing a bare `event.type !== 'directive'` check prevents `review_approved` and CI events from re-gating an already-approved task\n- When a `review_approved` event arrives and the deliver phase is already complete, call `mergeIfReady()` directly — extract merge logic into a shared public method so both `onDeliverComplete` and the `review_approved` path reuse it without duplication\n- Every phase-completion code path must call `phaseHooks.onPhaseComplete(task, phase)` explicitly — hooks inside `executePhase` do not fire on alternative paths (e.g., `review_approved` clearing the gate); a missing call silently skips lifecycle effects such as auto-merge even when the phase status is correctly written\n\n## Release Strategy\n\nA release is a configuration override that groups tasks and routes their PRs to an RC branch instead of master.\n\n### Data Model\n\n3 tables: `releases`, `release_tasks`, `release_repo_prs`.\n\n### Pipeline Integration\n\nWhen a task belongs to an active release:\n- `PhaseRouterService.buildEnv()` sets `PR_BASE_BRANCH` to the RC branch\n- The deliver skill creates PRs targeting the RC branch (not master)\n- `findActiveForTask(taskId)` is the key lookup — queries `release_tasks` → `releases`\n\n- When a task belongs to an active release, pass `rcBranch` as `baseBranch` to `getOrCreateWorktree()` (default `'master'`) — omitting it creates worktrees from master, not the RC branch; fetch the release config once in `executePhase()` and thread it through to both `getOrCreateWorktree()` and `buildEnv()` to avoid a duplicate `findActiveForTask()` call.\n- `GitHubService.findCommentByPrefix(owner, repo, issueNumber, prefix)` paginates issue comments (`per_page=100`) and returns `{ id, body } | null` — use it to locate a specific comment (e.g. `## Agent Plan`) before editing it.\n- `PhaseHooksService.onPlanComplete()` appends a `## Release Context` section (human-readable + collapsed JSON metadata) to the existing plan comment when the task has an active release; falls back to posting a standalone comment if the plan comment is not found (handles GitHub API propagation lag).\n\n### Status Lifecycle\n\n`draft` → `active` → `staging` → `validating` → `validated` → `shipping` → `shipped`\n(`failed` can transition back to `active` for retry)\n\n## Watchdog\n\n- Query `['active', 'gated']` statuses in `WatchdogService` — tasks waiting indefinitely for deliver-gate approval are invisible with `active`-only queries and accumulate as silent stuck issues\n- Expose `POST /internal/watchdog/run` (returns `{ stuck, slow }`) for on-demand detection — avoids E2E tests waiting 5 minutes for the cron cycle; `runCheck()` should be a public method on the service\n\n## Test Environment\n\nA test instance of agent-core runs alongside production for E2E verification before merging to master.\n\n- **Test clone:** `/home/agent/AgentSDE/testing/agent-core` (branch: `testing`)\n- **Port:** 8777, managed by pm2 as `agent-core-test`\n- **Database:** `agent-core-test.db` (configured via `DB_NAME` env var, separate from production `agent-core.db`)\n- **Nginx:** `wh.kontashera.com/test/*` proxies to `localhost:8777`\n- **Webhook:** Repo-level webhook on `AgentSDE/agent-core-e2e` delivers to `wh.kontashera.com/test/webhooks/github`\n- Never modify the test instance while production E2E or nightly runs are in progress\n- Always restore `agent-core-e2e/.env` `AGENT_CORE_URL` back to `http://localhost:3000` after testing\n- The `testing` branch can be force-pushed freely — it is not protected\n\n## Database Entities\n\nWhen adding a new TypeORM entity, update four locations atomically:\n1. Create entity file under `src/database/entities/<name>.entity.ts`\n2. Export from `src/database/entities/index.ts` barrel\n3. Add to the `forRoot` entities array in `DatabaseModule`\n4. Add to the `forFeature` array in `DatabaseModule`\n\nFor SQLite: use `type: 'integer'`/`type: 'real'` for numeric columns (TypeORM defaults don't map correctly); use `@Column({ name: 'snake_case_name' })` for consistent snake_case DB column naming.\n- When a service needs entities from another domain (e.g., `TaskEntity` in `ReleaseModule`), add them to the local `TypeOrmModule.forFeature()` array — do NOT import the owning module; avoids circular dependency risks.\n- When removing a TypeORM entity, mirror the four-location add pattern in reverse: delete the entity file, remove its export from `entities/index.ts`, remove it from the `forRoot` entities array in `DatabaseModule`, and remove `forFeature` references in all consumer modules (e.g., `OperationalModule`, `ControlApiModule`) and their spec files.\n- Before inserting into a join/relation table, always query existing rows and filter them out — silently inserting duplicates breaks unique constraints and is hard to diagnose at runtime.\n- When an issue specifies Postgres-style schema changes (migration files, `uuid_generate_v4()`, `timestamptz`), translate to a TypeORM entity with SQLite-compatible types (`@PrimaryGeneratedColumn('uuid')`, `datetime`) — no migration tooling exists in this repo; `synchronize: true` is the schema mechanism. Verify the schema in `database.module.spec.ts` with in-memory SQLite covering uuid generation, unique-constraint enforcement, and cross-tenant reuse.\n\n## REST Controllers\n\n- Throw HTTP exceptions (`NotFoundException`, etc.) in the controller layer, not in the service — services should return `null`/`undefined` for \"not found\" cases; the controller converts these to the appropriate HTTP status using NestJS built-in exceptions.\n- Register new controllers in their owning module and import that module into `AppModule` in the same PR — a controller not imported into `AppModule` is silently unreachable.\n\n## Redis / BullMQ\n\n- Use a dedicated `ioredis` instance (`lazyConnect: true`) in `HealthModule` for `/health` Redis probes — never share BullMQ's internal connection; health checks coupled to queue lifecycle report misleading states during degraded scenarios\n- Register queues via `BullModule.forRootAsync()` (ConfigService-driven) + `BullModule.registerQueue()` in `AppModule` — this is the established queue registration pattern\n- Redis pub/sub requires two separate ioredis clients — one dedicated to publish (`pub`), one to subscribe (`sub`); a client in subscribe mode cannot also publish. Follow the `EventBusModule` factory pattern. `emit()` must swallow Redis errors; per-handler error isolation in `on()` prevents one failing handler from blocking others — event delivery must never block pipeline advancement\n- If the BullMQ class manages its own `IORedis` connections directly, do NOT also register `BullModule.forRootAsync` in the same feature module — it conflicts with `InvokeModule`'s existing registration; use direct `IORedis` instances instead.\n- **BullMQ stall-retry idempotency**: Wrap `createTask()` in `route()` with try/catch; on unique-constraint error fall back to `findByIssueAndRepo()` — concurrent deliveries share the existing row. Guard phase hooks (`onPhaseBlocked`, `onCompoundComplete`) with per-phase event records before posting comments; skip if already recorded. Add early-return in `executePhase()` if phase is already `'active'`. Extend phase-event dedup guard to skip when `phaseStatus === 'failed' && task.status === 'gated'` — persistent-block stalls differ from transient retries (which keep `status === 'active'`).\n\n## Config Schema\n\n- Use Joi `.when()` for conditional env var validation in two directions: (a) make existing required fields optional when a new auth mode flag is present (`Joi.string().when('FLAG', { is: Joi.exist(), then: Joi.optional(), otherwise: Joi.required() })`); (b) make companion vars required when an optional provider's gateway var is set (e.g., `*_OAUTH_CLIENT_ID`). Designate one gateway var per provider group — partial configs missing the gateway silently pass (intentional; mirrors `GITHUB_APP_ID` pattern).\n- Config schema tests for conditional validators must cover three cases: (1) empty/legacy config passes with no error, (2) gateway set with all required companions passes, (3) gateway set but companions missing fails and lists the missing vars — case (3) is the partial-fail guard that prevents silent misconfiguration.\n\n## Important constraints\n\n- This service runs live on the VPS via pm2 (`pm2 restart agent-core` to reload)\n- Port `3000` is proxied through nginx — do not change the port without updating nginx config\n- `GITHUB_WEBHOOK_SECRET` must match the secret configured in the GitHub org webhook\n- The WAL file path (`agent-core-artefacts/watchdog/wal.jsonl`) is shared with the watchdog — do not change its location without updating both services\n- `ClaudeInvocationService.invoke()` is now the primary entry point for agent runs; `dispatch-v2.sh`/`DispatchService` is legacy and being superseded by the `invoke` module\n- `GITHUB_WEBHOOK_SECRET` must be read with `get()` (not `getOrThrow()`) and verification skipped when unset — E2E environments deliberately omit the secret; `getOrThrow()` returns HTTP 500 instead of gracefully ignoring the event\n- `TASK_DIR` is resolved by `ArtefactsService.buildCentralTaskDir()` and set in the Claude invocation env by `PhaseRouterService` — do not construct it independently in other services\n","structuredPatch":[{"oldStart":94,"oldLines":8,"newStart":94,"newLines":7,"lines":[" - New `phase<Name>` column on `TaskEntity` (with `DEFAULT 'pending'` for safe migration)"," - `phaseName: 'pending'` in `createTask()` defaults in `TaskStateService`"," - `onPhaseSkip` / `onPhaseComplete` hooks in `PhaseHooksService` if the phase needs lifecycle handling","-- New skill files under `.claude/skills/<phase>/` (SKILL.md + role.md)","-**PR sizing:** Even though all these files change together, consider splitting DB schema changes (migration + entity) into a separate PR from routing and skill logic — reviewers flag large pipeline changes as high-risk.","+- New skill files under `.claude/skills/<phase>/` (SKILL.md + role.md); consider splitting DB schema changes into a separate PR — reviewers flag large pipeline additions as high-risk"," - `phaseKey()` static map in `TaskStateService` — do NOT rely on dynamic camelCase derivation; it produces `phaseAddress_review` instead of `phaseAddressReview` for underscore-named phases; add an explicit entry to the static map for every new phase"," - `setPhaseStatus(task, phase, status)` only mutates the in-memory entity — it does not persist to DB; to save the change, pass the field explicitly in the `updateTaskStatus()` call (e.g., `{ phaseReview: 'skipped' }`); entity mutation alone is silently discarded and will not survive the next DB read"," "]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_017c5r5PVjpyb4AvVTpSgZx4","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TUfNBBrzNNfASEWKszCbaz","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","old_string":"- `GitHubService.findCommentByPrefix(owner, repo, issueNumber, prefix)` paginates issue comments (`per_page=100`) and returns `{ id, body } | null` — use it to locate a specific comment (e.g. `## Agent Plan`) before editing it.\n- When a task belongs to an active release, pass `rcBranch` as `baseBranch` to `getOrCreateWorktree()` (default `'master'`) — omitting it creates worktrees from master, not the RC branch; fetch the release config once in `executePhase()` and thread it through to both `getOrCreateWorktree()` and `buildEnv()` to avoid a duplicate `findActiveForTask()` call.\n- `PhaseHooksService.onPlanComplete()` appends a `## Release Context` section (human-readable + collapsed JSON metadata) to the existing plan comment when the task has an active release; falls back to posting a standalone comment if the plan comment is not found (handles GitHub API propagation lag).","new_string":"- When a task belongs to an active release, pass `rcBranch` as `baseBranch` to `getOrCreateWorktree()` (default `'master'`) — omitting it creates worktrees from master, not the RC branch; fetch the release config once in `executePhase()` and thread it through to both `getOrCreateWorktree()` and `buildEnv()` to avoid a duplicate `findActiveForTask()` call."},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":302,"cache_read_input_tokens":51056,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":302},"output_tokens":63,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"bc742def-bf7b-47cc-b9b7-acb3e41dc61e"}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"<tool_use_error>String to replace not found in file.\nString: - `GitHubService.findCommentByPrefix(owner, repo, issueNumber, prefix)` paginates issue comments (`per_page=100`) and returns `{ id, body } | null` — use it to locate a specific comment (e.g. `## Agent Plan`) before editing it.\n- When a task belongs to an active release, pass `rcBranch` as `baseBranch` to `getOrCreateWorktree()` (default `'master'`) — omitting it creates worktrees from master, not the RC branch; fetch the release config once in `executePhase()` and thread it through to both `getOrCreateWorktree()` and `buildEnv()` to avoid a duplicate `findActiveForTask()` call.\n- `PhaseHooksService.onPlanComplete()` appends a `## Release Context` section (human-readable + collapsed JSON metadata) to the existing plan comment when the task has an active release; falls back to posting a standalone comment if the plan comment is not found (handles GitHub API propagation lag).\n(note: Edit also tried swapping \\uXXXX escapes and their characters; neither form matched, so the mismatch is likely elsewhere in old_string. Re-read the file and copy the exact surrounding text.)</tool_use_error>","is_error":true,"tool_use_id":"toolu_01TUfNBBrzNNfASEWKszCbaz"}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"9fdedd13-6a9c-4557-882a-3029bec4e581","timestamp":"2026-04-24T17:32:04.095Z","tool_use_result":"Error: String to replace not found in file.\nString: - `GitHubService.findCommentByPrefix(owner, repo, issueNumber, prefix)` paginates issue comments (`per_page=100`) and returns `{ id, body } | null` — use it to locate a specific comment (e.g. `## Agent Plan`) before editing it.\n- When a task belongs to an active release, pass `rcBranch` as `baseBranch` to `getOrCreateWorktree()` (default `'master'`) — omitting it creates worktrees from master, not the RC branch; fetch the release config once in `executePhase()` and thread it through to both `getOrCreateWorktree()` and `buildEnv()` to avoid a duplicate `findActiveForTask()` call.\n- `PhaseHooksService.onPlanComplete()` appends a `## Release Context` section (human-readable + collapsed JSON metadata) to the existing plan comment when the task has an active release; falls back to posting a standalone comment if the plan comment is not found (handles GitHub API propagation lag).\n(note: Edit also tried swapping \\uXXXX escapes and their characters; neither form matched, so the mismatch is likely elsewhere in old_string. Re-read the file and copy the exact surrounding text.)"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01Y89WeKFQ3Jq1gN1xsj3Tb7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WxyEufyBf8fKuVyBUpd3Gs","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","offset":125,"limit":20},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":821,"cache_read_input_tokens":51358,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":821},"output_tokens":64,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"91d31256-1d07-4eb5-a10a-55bf598da608"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WxyEufyBf8fKuVyBUpd3Gs","type":"tool_result","content":"125\t- `PhaseRouterService.buildEnv()` sets `PR_BASE_BRANCH` to the RC branch\n126\t- The deliver skill creates PRs targeting the RC branch (not master)\n127\t- `findActiveForTask(taskId)` is the key lookup — queries `release_tasks` → `releases`\n128\t\n129\t- When a task belongs to an active release, pass `rcBranch` as `baseBranch` to `getOrCreateWorktree()` (default `'master'`) — omitting it creates worktrees from master, not the RC branch; fetch the release config once in `executePhase()` and thread it through to both `getOrCreateWorktree()` and `buildEnv()` to avoid a duplicate `findActiveForTask()` call.\n130\t- `GitHubService.findCommentByPrefix(owner, repo, issueNumber, prefix)` paginates issue comments (`per_page=100`) and returns `{ id, body } | null` — use it to locate a specific comment (e.g. `## Agent Plan`) before editing it.\n131\t- `PhaseHooksService.onPlanComplete()` appends a `## Release Context` section (human-readable + collapsed JSON metadata) to the existing plan comment when the task has an active release; falls back to posting a standalone comment if the plan comment is not found (handles GitHub API propagation lag).\n132\t\n133\t### Status Lifecycle\n134\t\n135\t`draft` → `active` → `staging` → `validating` → `validated` → `shipping` → `shipped`\n136\t(`failed` can transition back to `active` for retry)\n137\t\n138\t## Watchdog\n139\t\n140\t- Query `['active', 'gated']` statuses in `WatchdogService` — tasks waiting indefinitely for deliver-gate approval are invisible with `active`-only queries and accumulate as silent stuck issues\n141\t- Expose `POST /internal/watchdog/run` (returns `{ stuck, slow }`) for on-demand detection — avoids E2E tests waiting 5 minutes for the cron cycle; `runCheck()` should be a public method on the service\n142\t\n143\t## Test Environment\n144\t"}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"ac602fe2-5970-43dc-b072-9d9abbc2cdda","timestamp":"2026-04-24T17:32:06.296Z","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","content":"- `PhaseRouterService.buildEnv()` sets `PR_BASE_BRANCH` to the RC branch\n- The deliver skill creates PRs targeting the RC branch (not master)\n- `findActiveForTask(taskId)` is the key lookup — queries `release_tasks` → `releases`\n\n- When a task belongs to an active release, pass `rcBranch` as `baseBranch` to `getOrCreateWorktree()` (default `'master'`) — omitting it creates worktrees from master, not the RC branch; fetch the release config once in `executePhase()` and thread it through to both `getOrCreateWorktree()` and `buildEnv()` to avoid a duplicate `findActiveForTask()` call.\n- `GitHubService.findCommentByPrefix(owner, repo, issueNumber, prefix)` paginates issue comments (`per_page=100`) and returns `{ id, body } | null` — use it to locate a specific comment (e.g. `## Agent Plan`) before editing it.\n- `PhaseHooksService.onPlanComplete()` appends a `## Release Context` section (human-readable + collapsed JSON metadata) to the existing plan comment when the task has an active release; falls back to posting a standalone comment if the plan comment is not found (handles GitHub API propagation lag).\n\n### Status Lifecycle\n\n`draft` → `active` → `staging` → `validating` → `validated` → `shipping` → `shipped`\n(`failed` can transition back to `active` for retry)\n\n## Watchdog\n\n- Query `['active', 'gated']` statuses in `WatchdogService` — tasks waiting indefinitely for deliver-gate approval are invisible with `active`-only queries and accumulate as silent stuck issues\n- Expose `POST /internal/watchdog/run` (returns `{ stuck, slow }`) for on-demand detection — avoids E2E tests waiting 5 minutes for the cron cycle; `runCheck()` should be a public method on the service\n\n## Test Environment\n","numLines":20,"startLine":125,"totalLines":197}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01AMs5h1X2UG6YtKU5Ku8h8G","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016o3eBGy1bF8JUbBMMiCSYJ","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","old_string":"- `GitHubService.findCommentByPrefix(owner, repo, issueNumber, prefix)` paginates issue comments (`per_page=100`) and returns `{ id, body } | null` — use it to locate a specific comment (e.g. `## Agent Plan`) before editing it.\n- `PhaseHooksService.onPlanComplete()` appends a `## Release Context` section (human-readable + collapsed JSON metadata) to the existing plan comment when the task has an active release; falls back to posting a standalone comment if the plan comment is not found (handles GitHub API propagation lag).","new_string":""},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1477,"cache_read_input_tokens":51358,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1477},"output_tokens":62,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"d9cd554b-712f-418b-ba41-754246924fdd"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016o3eBGy1bF8JUbBMMiCSYJ","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"d1efc5f2-ae9a-471c-9a7c-b4782e3a2c86","timestamp":"2026-04-24T17:32:09.579Z","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","oldString":"- `GitHubService.findCommentByPrefix(owner, repo, issueNumber, prefix)` paginates issue comments (`per_page=100`) and returns `{ id, body } | null` — use it to locate a specific comment (e.g. `## Agent Plan`) before editing it.\n- `PhaseHooksService.onPlanComplete()` appends a `## Release Context` section (human-readable + collapsed JSON metadata) to the existing plan comment when the task has an active release; falls back to posting a standalone comment if the plan comment is not found (handles GitHub API propagation lag).","newString":"","originalFile":"# Agent Configuration — agent-core\n\n## Purpose\n\nThis is the core orchestration server for the AgentSDE pipeline. It is a production NestJS application. Changes here affect the live webhook handler and all downstream agent runs.\n\n## Skills Available\n\n- `/orchestrate` — used for all issue work (plan + implement + PR)\n- `/review-pr` — used when reviewing PRs on this repo\n\n## Workflow\n\n1. Read the issue carefully — understand the impact on the webhook pipeline\n2. Read the relevant source files before making changes\n3. Run `npm run test` locally to verify unit tests pass\n4. Run `npm run lint` — zero warnings are allowed\n5. Create branch: `feat/<issue-number>-<slug>` or `fix/<issue-number>-<slug>`\n6. Commit with message: `feat(<scope>): <what>` or `fix(<scope>): <what>`\n7. Open PR with `Closes #<issue-number>` in the body\n8. Do not merge your own PR — CI must pass first\n9. When adding a new module, env var, or dev command, update CLAUDE.md in the same PR — `.env.example` is the canonical env vars reference; `src/` modules should all appear in the Key modules list\n10. For documentation-only PRs (no TS/JS modified), steps 3–4 (test and lint) may be skipped — state this explicitly in the pre-commit checks comment\n\n## Task Phases\n\n1. `plan` — read the issue and relevant source files, identify the change\n2. `implement` — make the change, write or update unit tests\n3. `test` — run `npm run test` and `npm run lint`, fix any failures\n4. `review` — open PR, wait for human approval gate\n5. `deploy` — handled by CI on merge to master\n\n## Artefacts\n\nTask state is written to `AgentSDE/agent-core-artefacts` repo, branch `AgentSDE/agent-core/<issue-number>`.\n\n## Directive System\n\n- `ALLOWED_DIRECTIVES` in `directive.dto.ts` is the single source of truth — `DirectiveValue` union type auto-derives from it\n- New directives must be wired in **two places**: `DirectiveService.applyDirective()` (internal API path) and `PhaseRouterService.route()` (GitHub webhook path); missing either creates a silent gap\n- Transient directives (`retry`, `restart`) must **not** overwrite `task.directive` — preserve the task's execution directive (`auto`/`careful`/`quick`)\n- Always post a GitHub feedback comment when a directive is rejected (terminal state, live process, unrecognised) — never drop silently\n- When shipping a new directive or pipeline behavior, add its AGENTS.md documentation in the same PR — deferred documentation accumulates as separate cleanup issues\n- Before closing an open PR on replan/restart, always post a \"superseded\" comment explaining the closure — silent PR closes are flagged by reviewers\n- Each directive that resets task state should have its own dedicated reset method (`resetForReplan`, `resetForRestart`) with distinct semantics — avoid sharing or overloading a single method with conditional branching\n- `DirectiveService.applyDirective()` does not enforce actor-level authorization — that responsibility lives at the GitHub webhook layer. In tests, document this boundary with a `TODO` and a reference issue rather than asserting service-level rejection\n\n### `skip-quality-gate` Directive\n- One-time bypass (`/agentsde skip-quality-gate`, legacy: `/agent skip-quality-gate`) of pre-PR quality gate before deliver phase. Sets `task.qualityGateOverride = true`; consumed atomically at start of next deliver phase (reset to `false` before invocation — cannot carry over). Does not skip code review. Confirmation comment posted on set. **Source:** `directive.service.ts` + `phase-router.service.ts`.\n\n### `refine` Directive\n- Re-runs refine phase on a `gated` task. Rejected with a feedback comment if `task.status` is anything other than `gated` (terminal, active, blocked, paused). **Source:** `directive.service.ts` (`applyDirective`, refine branch).\n\n## Multi-Tenant Consumer Migration (Wave 2+)\n\nWhen migrating any service from `GitHubService` to an abstract provider (e.g., `PlatformProvider`), update three layers atomically: (1) swap `@Inject(PLATFORM_PROVIDER) platform: PlatformProvider` in the constructor (remove `GitHubService` import), (2) replace `GitHubModule` with `PlatformModule` in the module `imports` array, (3) switch the spec mock from `{ provide: GitHubService, useValue: {...} }` to `{ provide: PLATFORM_PROVIDER, useValue: {...} }` and update the retrieved mock type to `jest.Mocked<PlatformProvider>`. Methods typed as optional on the provider interface (e.g., `setProjectItemStatus`) must use `?.()` — this is correct and intentional, not a typing gap.\n\n- PRs that are part of Wave 2+ multi-tenant migration should target `rc/multi-tenant` as the base branch — PRs targeting `master` will be redirected by reviewers. Check whether this branch exists before opening the PR.\n- PRs that are part of the token usage tracking feature series (TU-*) should target `rc/token-usage` as the base branch, not `master` — same pattern as `rc/multi-tenant` for the multi-tenant wave. Token usage types live in `src/llm/llm-provider.interface.ts` (`UsageEntry`, `UsageData`); both `PhaseResult` and `SignalResult` carry `usage?: UsageData`.\n- PRs that are part of the release strategy feature series (RS-*) should target `rc/release-strategy` as the base branch, not `master` — same pattern as `rc/multi-tenant` and `rc/token-usage`; check whether this branch exists before opening the PR.\n- PRs that are part of the GitHub Apps feature series (GA-*) should target `rc/github-apps` as the base branch, not `master` — same pattern as `rc/multi-tenant`, `rc/token-usage`, and `rc/release-strategy`; check whether this branch exists before opening the PR. Same applies to Atlassian integration (BJ-*) → `rc/atlassian-integration`. Same applies to Skills MCP Platform (SM-*) → `rc/skills-mcp-platform`.\n- GitHub App auth: sign RS256 JWTs via Node.js `crypto.createSign('RSA-SHA256')` (no external JWT library); read PEM from disk path (not inline env var); use a promise-based lock so concurrent `getToken()` callers share one refresh promise rather than triggering N parallel API calls; refresh proactively when < 5 min remain on the cached installation token. `GitHubModule` registers a `useFactory` for `PLATFORM_AUTH_PROVIDER` that selects `GitHubAppAuthProvider` vs `GitHubPatAuthProvider` based on `GITHUB_APP_ID` presence, and exports the token — importing `GitHubModule` gives consumers both `GitHubService` and `PLATFORM_AUTH_PROVIDER` without additional setup. Call `await this.authProvider.getToken()` inline per request (not cached at construction) to support token refresh. `getBotUsername()` is synchronous and stable — cache it at construction as `private readonly botUsername` rather than calling per event handler; modules that did not previously import `GitHubModule` must add it to their `imports` to expose `PLATFORM_AUTH_PROVIDER`. When migrating static config vars (e.g., `GITHUB_TOKEN`, `AGENT_USERNAME`) to provider resolution, remove them from `configKeys` in `ClaudeInvocationService` — leftover static entries silently shadow the fresh provider values in the worker env.\n- `PR_BASE_BRANCH` in the Claude invocation env overrides the deliver-phase PR target branch — set by `PhaseRouterService.buildEnv()` via `ReleaseService.findActiveForTask(taskId)`; returns `{ rcBranch, releaseId, baseBranch, key, title, status, directive }` when a task is in an active release, `null` otherwise. `buildEnv()` is async for this reason.\n- When a Wave N dependency module (e.g., `PlatformModule`) is not yet merged, provide a minimal inline provider directly in the consuming module rather than importing the missing module — this unblocks Wave N+1 work without waiting for all dependencies to land; remove the stub once the real module is available.\n- `AtlassianOAuthAuthProvider` follows `GitHubAppAuthProvider`'s 5-min cache + promise concurrency-guard pattern with one critical addition: Atlassian rotates the refresh token on every call — persist via `stateRepo.saveRefreshToken` **before** returning the access token; a crash after refresh but before persistence leaves a consumed token that cannot be retried (permanent re-auth required).\n- BJ-* Atlassian platform modules (`JiraModule`, `BitbucketModule`) each export a platform-specific DI token (`JIRA_AUTH_PROVIDER`, `BITBUCKET_AUTH_PROVIDER`) — NOT the shared `PLATFORM_AUTH_PROVIDER`; root wiring into the generic token is BJ-11. Services that need the unified `PLATFORM_AUTH_PROVIDER` must wait for BJ-11; exception: `InvokeModule` imports platform modules with `@Optional()` and collects non-null providers into `PLATFORM_AUTH_PROVIDERS` (array token) — `ClaudeInvocationService.invoke()` selects by `provider.platform === env['PLATFORM']`, throws for unrecognised platforms, always stamps `env['PLATFORM']` on outgoing env, and does not overwrite caller-supplied values. Env keys per platform: github `{GITHUB_TOKEN, AGENT_USERNAME, GITHUB_OWNER, GITHUB_REPO, PROJECT_ID, PROJECT_NUMBER}`, bitbucket `{BITBUCKET_TOKEN, BITBUCKET_BOT_USERNAME, BITBUCKET_WORKSPACE, BITBUCKET_REPO}`, jira `{JIRA_TOKEN, JIRA_BOT_USERNAME, JIRA_SITE_URL, JIRA_CLOUD_ID, JIRA_PROJECT_KEY}`. Factory providers return `null` when the gateway env var (`*_OAUTH_CLIENT_ID`) is unset — consumers must type-guard before use; app boots cleanly without optional credentials. Define DI tokens in the service file and re-export from the module to prevent circular imports between service and module.\n- Jira REST v3 API conventions: comment/description bodies must be ADF-wrapped (`{ type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] }`); labels mutate via `update: { labels: [{ add: 'lbl' }] }` / `[{ remove: 'lbl' }]` on PUT `/rest/api/3/issue/{key}` — NOT `fields.labels` replacement; `setAssignee` uses a dedicated PUT endpoint `/assignee` with `{ accountId }`.\n- Reuse upstream injection tokens (e.g., `LLM_PROVIDER` from `llm-provider.interface.ts`) rather than defining a local duplicate — token mismatches cause silent DI failures that are hard to diagnose.\n\n## Testing Conventions\n\n- Never hardcode production paths (e.g., `/home/agent/AgentSDE/tasks/...`) in test files — CI machines don't have these directories; use `os.tmpdir()` or a `testTmpDir` fixture instead\n- `ClaudeInvocationService` parses stdout as JSON lines (stream-json format); test stubs must wrap signals in JSON objects, not raw strings\n- When multiple services derive the same filesystem path, centralize it in one helper (e.g., `ArtefactsService.buildCentralTaskDir()`) — prevents path drift across services\n- Prefer real temp dirs over filesystem mocks for I/O-heavy services — mocking `fs` in unit tests for services like `ClaudeInvocationService` raises reviewer concerns and can miss real I/O edge cases\n- When injecting a new shared service into multiple existing modules, update all three layers per consumer: (1) constructor injection in the service, (2) the consumer's module `imports` array (e.g., `TenantModule`), and (3) a mock provider in each `.spec.ts` — reviewers flag any missing layer as a DI breakage risk\n- When pre-existing test failures exist on `master` unrelated to the change, call them out explicitly in the PR body (e.g., \"6 pre-existing watchdog failures unchanged\") — reviewers cannot distinguish regressions from known failures without this\n- When adding a new pipeline status or phase transition, always cover three test scenarios: (1) idempotent re-entry (target state already set), (2) happy path, and (3) recovery path where the triggering condition resolves before the threshold — reviewers flag gaps in these as non-obvious but high-value coverage\n- When testing services that read env vars directly, save and restore original values in `beforeEach`/`afterEach` — unrestored env mutations (e.g., `STUCK_THRESHOLD_MS`, `SLOW_THRESHOLD_MS`) silently corrupt other test suites running in the same Jest worker process\n- For directive integration tests, wire `DirectiveService` with real `TaskStateService` + in-memory SQLite and mocked `GitHubService`/`ArtefactsService` — follow the `blocked-persistent-to-refine` pattern; each new directive spec should cover (1) happy path with open PR, (2) no-open-PR variant, and (3) terminal/active task rejection guard\n- A cancelled E2E CI job (step interrupted by infra timeout, not an assertion failure) is not a code regression — re-trigger E2E after the fix; if the timeout recurs, escalate as a CI infra issue rather than treating it as a test failure\n\n## Logging Conventions\n\n- `{taskDir}/meta/` is the canonical per-task log directory — already included in `ARTEFACT_SUBDIRS`; files written there are synced to `agent-core-artefacts` automatically without further config changes\n- When piping spawned-process stdout/stderr to a file write stream, open in append mode (`flags: 'a'`) and close the stream in both the `close` and `error` event handlers to avoid resource leaks\n- In cleanup methods that `unlink` files in a loop, only swallow `ENOENT` errors; re-throw or log all other errors — broad `catch` blocks in cleanup paths are reviewers' top flag for silent data-loss risk\n\n## Adding a New Pipeline Phase\n\nWhen adding a new SDLC phase, all of the following must be updated in the same PR:\n- `PHASE_ORDER` array and `phaseColumn()` map in `InternalAdapterService`\n- `PHASE_TO_SKILL` map in `ClaudeInvocationService`\n- New `phase<Name>` column on `TaskEntity` (with `DEFAULT 'pending'` for safe migration)\n- `phaseName: 'pending'` in `createTask()` defaults in `TaskStateService`\n- `onPhaseSkip` / `onPhaseComplete` hooks in `PhaseHooksService` if the phase needs lifecycle handling\n- New skill files under `.claude/skills/<phase>/` (SKILL.md + role.md); consider splitting DB schema changes into a separate PR — reviewers flag large pipeline additions as high-risk\n- `phaseKey()` static map in `TaskStateService` — do NOT rely on dynamic camelCase derivation; it produces `phaseAddress_review` instead of `phaseAddressReview` for underscore-named phases; add an explicit entry to the static map for every new phase\n- `setPhaseStatus(task, phase, status)` only mutates the in-memory entity — it does not persist to DB; to save the change, pass the field explicitly in the `updateTaskStatus()` call (e.g., `{ phaseReview: 'skipped' }`); entity mutation alone is silently discarded and will not survive the next DB read\n\n## Worktree Management\n\n- Use idempotent `getOrCreateWorktree()` instead of per-phase create/destroy: check if worktree is registered via `git worktree list --porcelain`, attach an existing branch without `-b`, or fresh-create — avoids `git branch -D` failures on checked-out branches that cause permanent stuck loops\n- Move worktree cleanup to terminal state (`complete`) in `InternalAdapterService`, not in `finally` blocks inside `PhaseRouterService` — per-phase cleanup destroys worktrees still needed by subsequent phases\n- Tasks that never reach `complete` (permanently blocked or abandoned) accumulate worktrees indefinitely — any cancellation or watchdog path that abandons a task must also call `cleanupWorktree()`\n- On worktree reuse, run only `git fetch origin` to update remote-tracking refs — never `git reset --hard origin/master`; the branch already holds agent commits from prior phases, and a hard reset silently destroys them\n\n## Gate & Merge\n\n- Use an explicit `GATE_BYPASS_EVENT_TYPES = new Set(['directive', 'review_approved', 'ci_passed', 'ci_failure'])` in `PhaseRouterService` — replacing a bare `event.type !== 'directive'` check prevents `review_approved` and CI events from re-gating an already-approved task\n- When a `review_approved` event arrives and the deliver phase is already complete, call `mergeIfReady()` directly — extract merge logic into a shared public method so both `onDeliverComplete` and the `review_approved` path reuse it without duplication\n- Every phase-completion code path must call `phaseHooks.onPhaseComplete(task, phase)` explicitly — hooks inside `executePhase` do not fire on alternative paths (e.g., `review_approved` clearing the gate); a missing call silently skips lifecycle effects such as auto-merge even when the phase status is correctly written\n\n## Release Strategy\n\nA release is a configuration override that groups tasks and routes their PRs to an RC branch instead of master.\n\n### Data Model\n\n3 tables: `releases`, `release_tasks`, `release_repo_prs`.\n\n### Pipeline Integration\n\nWhen a task belongs to an active release:\n- `PhaseRouterService.buildEnv()` sets `PR_BASE_BRANCH` to the RC branch\n- The deliver skill creates PRs targeting the RC branch (not master)\n- `findActiveForTask(taskId)` is the key lookup — queries `release_tasks` → `releases`\n\n- When a task belongs to an active release, pass `rcBranch` as `baseBranch` to `getOrCreateWorktree()` (default `'master'`) — omitting it creates worktrees from master, not the RC branch; fetch the release config once in `executePhase()` and thread it through to both `getOrCreateWorktree()` and `buildEnv()` to avoid a duplicate `findActiveForTask()` call.\n- `GitHubService.findCommentByPrefix(owner, repo, issueNumber, prefix)` paginates issue comments (`per_page=100`) and returns `{ id, body } | null` — use it to locate a specific comment (e.g. `## Agent Plan`) before editing it.\n- `PhaseHooksService.onPlanComplete()` appends a `## Release Context` section (human-readable + collapsed JSON metadata) to the existing plan comment when the task has an active release; falls back to posting a standalone comment if the plan comment is not found (handles GitHub API propagation lag).\n\n### Status Lifecycle\n\n`draft` → `active` → `staging` → `validating` → `validated` → `shipping` → `shipped`\n(`failed` can transition back to `active` for retry)\n\n## Watchdog\n\n- Query `['active', 'gated']` statuses in `WatchdogService` — tasks waiting indefinitely for deliver-gate approval are invisible with `active`-only queries and accumulate as silent stuck issues\n- Expose `POST /internal/watchdog/run` (returns `{ stuck, slow }`) for on-demand detection — avoids E2E tests waiting 5 minutes for the cron cycle; `runCheck()` should be a public method on the service\n\n## Test Environment\n\nA test instance of agent-core runs alongside production for E2E verification before merging to master.\n\n- **Test clone:** `/home/agent/AgentSDE/testing/agent-core` (branch: `testing`)\n- **Port:** 8777, managed by pm2 as `agent-core-test`\n- **Database:** `agent-core-test.db` (configured via `DB_NAME` env var, separate from production `agent-core.db`)\n- **Nginx:** `wh.kontashera.com/test/*` proxies to `localhost:8777`\n- **Webhook:** Repo-level webhook on `AgentSDE/agent-core-e2e` delivers to `wh.kontashera.com/test/webhooks/github`\n- Never modify the test instance while production E2E or nightly runs are in progress\n- Always restore `agent-core-e2e/.env` `AGENT_CORE_URL` back to `http://localhost:3000` after testing\n- The `testing` branch can be force-pushed freely — it is not protected\n\n## Database Entities\n\nWhen adding a new TypeORM entity, update four locations atomically:\n1. Create entity file under `src/database/entities/<name>.entity.ts`\n2. Export from `src/database/entities/index.ts` barrel\n3. Add to the `forRoot` entities array in `DatabaseModule`\n4. Add to the `forFeature` array in `DatabaseModule`\n\nFor SQLite: use `type: 'integer'`/`type: 'real'` for numeric columns (TypeORM defaults don't map correctly); use `@Column({ name: 'snake_case_name' })` for consistent snake_case DB column naming.\n- When a service needs entities from another domain (e.g., `TaskEntity` in `ReleaseModule`), add them to the local `TypeOrmModule.forFeature()` array — do NOT import the owning module; avoids circular dependency risks.\n- When removing a TypeORM entity, mirror the four-location add pattern in reverse: delete the entity file, remove its export from `entities/index.ts`, remove it from the `forRoot` entities array in `DatabaseModule`, and remove `forFeature` references in all consumer modules (e.g., `OperationalModule`, `ControlApiModule`) and their spec files.\n- Before inserting into a join/relation table, always query existing rows and filter them out — silently inserting duplicates breaks unique constraints and is hard to diagnose at runtime.\n- When an issue specifies Postgres-style schema changes (migration files, `uuid_generate_v4()`, `timestamptz`), translate to a TypeORM entity with SQLite-compatible types (`@PrimaryGeneratedColumn('uuid')`, `datetime`) — no migration tooling exists in this repo; `synchronize: true` is the schema mechanism. Verify the schema in `database.module.spec.ts` with in-memory SQLite covering uuid generation, unique-constraint enforcement, and cross-tenant reuse.\n\n## REST Controllers\n\n- Throw HTTP exceptions (`NotFoundException`, etc.) in the controller layer, not in the service — services should return `null`/`undefined` for \"not found\" cases; the controller converts these to the appropriate HTTP status using NestJS built-in exceptions.\n- Register new controllers in their owning module and import that module into `AppModule` in the same PR — a controller not imported into `AppModule` is silently unreachable.\n\n## Redis / BullMQ\n\n- Use a dedicated `ioredis` instance (`lazyConnect: true`) in `HealthModule` for `/health` Redis probes — never share BullMQ's internal connection; health checks coupled to queue lifecycle report misleading states during degraded scenarios\n- Register queues via `BullModule.forRootAsync()` (ConfigService-driven) + `BullModule.registerQueue()` in `AppModule` — this is the established queue registration pattern\n- Redis pub/sub requires two separate ioredis clients — one dedicated to publish (`pub`), one to subscribe (`sub`); a client in subscribe mode cannot also publish. Follow the `EventBusModule` factory pattern. `emit()` must swallow Redis errors; per-handler error isolation in `on()` prevents one failing handler from blocking others — event delivery must never block pipeline advancement\n- If the BullMQ class manages its own `IORedis` connections directly, do NOT also register `BullModule.forRootAsync` in the same feature module — it conflicts with `InvokeModule`'s existing registration; use direct `IORedis` instances instead.\n- **BullMQ stall-retry idempotency**: Wrap `createTask()` in `route()` with try/catch; on unique-constraint error fall back to `findByIssueAndRepo()` — concurrent deliveries share the existing row. Guard phase hooks (`onPhaseBlocked`, `onCompoundComplete`) with per-phase event records before posting comments; skip if already recorded. Add early-return in `executePhase()` if phase is already `'active'`. Extend phase-event dedup guard to skip when `phaseStatus === 'failed' && task.status === 'gated'` — persistent-block stalls differ from transient retries (which keep `status === 'active'`).\n\n## Config Schema\n\n- Use Joi `.when()` for conditional env var validation in two directions: (a) make existing required fields optional when a new auth mode flag is present (`Joi.string().when('FLAG', { is: Joi.exist(), then: Joi.optional(), otherwise: Joi.required() })`); (b) make companion vars required when an optional provider's gateway var is set (e.g., `*_OAUTH_CLIENT_ID`). Designate one gateway var per provider group — partial configs missing the gateway silently pass (intentional; mirrors `GITHUB_APP_ID` pattern).\n- Config schema tests for conditional validators must cover three cases: (1) empty/legacy config passes with no error, (2) gateway set with all required companions passes, (3) gateway set but companions missing fails and lists the missing vars — case (3) is the partial-fail guard that prevents silent misconfiguration.\n\n## Important constraints\n\n- This service runs live on the VPS via pm2 (`pm2 restart agent-core` to reload)\n- Port `3000` is proxied through nginx — do not change the port without updating nginx config\n- `GITHUB_WEBHOOK_SECRET` must match the secret configured in the GitHub org webhook\n- The WAL file path (`agent-core-artefacts/watchdog/wal.jsonl`) is shared with the watchdog — do not change its location without updating both services\n- `ClaudeInvocationService.invoke()` is now the primary entry point for agent runs; `dispatch-v2.sh`/`DispatchService` is legacy and being superseded by the `invoke` module\n- `GITHUB_WEBHOOK_SECRET` must be read with `get()` (not `getOrThrow()`) and verification skipped when unset — E2E environments deliberately omit the secret; `getOrThrow()` returns HTTP 500 instead of gracefully ignoring the event\n- `TASK_DIR` is resolved by `ArtefactsService.buildCentralTaskDir()` and set in the Claude invocation env by `PhaseRouterService` — do not construct it independently in other services\n","structuredPatch":[{"oldStart":127,"oldLines":8,"newStart":127,"newLines":6,"lines":[" - `findActiveForTask(taskId)` is the key lookup — queries `release_tasks` → `releases`"," "," - When a task belongs to an active release, pass `rcBranch` as `baseBranch` to `getOrCreateWorktree()` (default `'master'`) — omitting it creates worktrees from master, not the RC branch; fetch the release config once in `executePhase()` and thread it through to both `getOrCreateWorktree()` and `buildEnv()` to avoid a duplicate `findActiveForTask()` call.","-- `GitHubService.findCommentByPrefix(owner, repo, issueNumber, prefix)` paginates issue comments (`per_page=100`) and returns `{ id, body } | null` — use it to locate a specific comment (e.g. `## Agent Plan`) before editing it.","-- `PhaseHooksService.onPlanComplete()` appends a `## Release Context` section (human-readable + collapsed JSON metadata) to the existing plan comment when the task has an active release; falls back to posting a standalone comment if the plan comment is not found (handles GitHub API propagation lag)."," "," ### Status Lifecycle"," "]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01UnZQcvjdXSeqCkhwGwCxEA","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me check the current line count before adding the new section."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":329,"cache_read_input_tokens":52835,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":329},"output_tokens":6,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"0e542f49-c535-4d8a-b197-cfaf225f6434"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01UnZQcvjdXSeqCkhwGwCxEA","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01YVCAzzUyxN8eUw5UCojKZA","name":"Bash","input":{"command":"wc -l /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","description":"Count lines in AGENTS.md"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":329,"cache_read_input_tokens":52835,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":329},"output_tokens":6,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"50377e45-2309-4492-aa71-bba29e431b78"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01YVCAzzUyxN8eUw5UCojKZA","type":"tool_result","content":"194 /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","is_error":false}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"e5210b8e-9124-461e-8651-411f1424e971","timestamp":"2026-04-24T17:32:11.983Z","tool_use_result":{"stdout":"194 /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_014F9tpEtdikRUHwWgGFQ434","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"194 lines. I can add up to 5 lines. Let me add the MCP Dispatchers section. With 6 lines (blank + header + blank + 3 bullets), I'll be at 200. \n\nWait: 194 + 6 = 200. Perfect. Let me add:\n```\n\n## MCP Dispatchers (SM-*)\n\n- bullet 1\n- bullet 2\n- bullet 3\n```\n\nThat's: 1 blank line + 1 header line + 1 blank line + 3 bullet lines = 6 lines total. \n\nThat hits exactly 200 lines.","signature":"EqQEClsIDRgCKkB7PDMchdmS3rRNMH8mDbaSTpoGBTaFC/YlelRzZXIClaNRcDO5xjddazL9sPLtEstCiMTCyUsBep98iDlMyOTfMhFjbGF1ZGUtc29ubmV0LTQtNjgAEgwvDSqYw3PKZzdCEIoaDHQsn4pzmj6KMkbYQiIwy4rLY/heHkpD4qb0sP29oHnfs4UXEmDpqtoQm3QRzGIuVtpUh+ITRc2IcGTWn8OnKvYCFihQ66J7z/8Jm7pIIgCJs9KDtZNdKh3GJbexc4wUZ8IBn2mIRdhsN96hEuGSA26k3kZxn2U6lMxpgzFJ6NBcdJZmKMUKtAqHhrGCHujRnSeDWR7brqQ5OxZn5mjpzPvGV8Va5EhIAMWnJmZm/grwTfK4StIsLy7/wMI6OHZ67RMrkbgNlLqGgXrdFBrtUuOFDq9+XnF5FbURFLEf/2upX+7ErCRnqLCXOAVL5JRszahXGpj83BKs6CX6hp9enu5mPi2Hy3lf5R1t2tX488qyt/VzrapwgWoF/ZCvWvkePZnF9xHfZJhQI5U04UE9yTU3KVQRAioL5du8j/SVl6+oDGbQ3Zr+YN/fpvX3UzE2XWCUX+D47UiBjhK+rMrAki18J8MaeZly2xPU5D9Atz4YaGQ1W4kAhUikJ4iMdI7V+DfGZ/WFvIzfTW7bBUSnCSg5c/oSjD141xbLkVvn2fG94ArE+SbZZnrsl4kMbd1Lz8AGMT/nZiYYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":175,"cache_read_input_tokens":53164,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":175},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"c9a6c171-f7e7-44eb-9479-3b87e34dc827"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_014F9tpEtdikRUHwWgGFQ434","type":"message","role":"assistant","content":[{"type":"text","text":"194 lines — I can add exactly 6 lines to hit 200. Let me verify the end of the file and add the new section."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":175,"cache_read_input_tokens":53164,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":175},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"70890bf2-49c9-4e98-adb7-3b4dedba9af4"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_014F9tpEtdikRUHwWgGFQ434","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KUj5t7Shom1r4B5MLYa5S4","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","offset":187,"limit":10},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":175,"cache_read_input_tokens":53164,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":175},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"9f53be92-b67b-42ba-92a2-48f527b666a1"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KUj5t7Shom1r4B5MLYa5S4","type":"tool_result","content":"187\t\n188\t- This service runs live on the VPS via pm2 (`pm2 restart agent-core` to reload)\n189\t- Port `3000` is proxied through nginx — do not change the port without updating nginx config\n190\t- `GITHUB_WEBHOOK_SECRET` must match the secret configured in the GitHub org webhook\n191\t- The WAL file path (`agent-core-artefacts/watchdog/wal.jsonl`) is shared with the watchdog — do not change its location without updating both services\n192\t- `ClaudeInvocationService.invoke()` is now the primary entry point for agent runs; `dispatch-v2.sh`/`DispatchService` is legacy and being superseded by the `invoke` module\n193\t- `GITHUB_WEBHOOK_SECRET` must be read with `get()` (not `getOrThrow()`) and verification skipped when unset — E2E environments deliberately omit the secret; `getOrThrow()` returns HTTP 500 instead of gracefully ignoring the event\n194\t- `TASK_DIR` is resolved by `ArtefactsService.buildCentralTaskDir()` and set in the Claude invocation env by `PhaseRouterService` — do not construct it independently in other services\n195\t"}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"a3c8f9a1-2fdf-4a06-a292-6d83244fc484","timestamp":"2026-04-24T17:32:19.414Z","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","content":"\n- This service runs live on the VPS via pm2 (`pm2 restart agent-core` to reload)\n- Port `3000` is proxied through nginx — do not change the port without updating nginx config\n- `GITHUB_WEBHOOK_SECRET` must match the secret configured in the GitHub org webhook\n- The WAL file path (`agent-core-artefacts/watchdog/wal.jsonl`) is shared with the watchdog — do not change its location without updating both services\n- `ClaudeInvocationService.invoke()` is now the primary entry point for agent runs; `dispatch-v2.sh`/`DispatchService` is legacy and being superseded by the `invoke` module\n- `GITHUB_WEBHOOK_SECRET` must be read with `get()` (not `getOrThrow()`) and verification skipped when unset — E2E environments deliberately omit the secret; `getOrThrow()` returns HTTP 500 instead of gracefully ignoring the event\n- `TASK_DIR` is resolved by `ArtefactsService.buildCentralTaskDir()` and set in the Claude invocation env by `PhaseRouterService` — do not construct it independently in other services\n","numLines":9,"startLine":187,"totalLines":195}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01EXn7iSuymRTTuLhDgDaNRs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TugpqoWbhJrK2XxM1qHt1n","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","old_string":"- `TASK_DIR` is resolved by `ArtefactsService.buildCentralTaskDir()` and set in the Claude invocation env by `PhaseRouterService` — do not construct it independently in other services","new_string":"- `TASK_DIR` is resolved by `ArtefactsService.buildCentralTaskDir()` and set in the Claude invocation env by `PhaseRouterService` — do not construct it independently in other services\n\n## MCP Dispatchers (SM-*)\n\n- Platform-specific dispatchers for a shared tool: use `registry.has()` + `registry.replaceHandler()` to wrap the existing handler — route by `repo_ref` platform prefix, fall through to captured handler for non-matching refs; never call `registry.register()` on an already-registered tool (clobbers the prior handler)\n- MCP tool errors use `{ ok: false, code: ErrorCode, message, retriable, platform }` — HTTP status map: 404 → `NOT_FOUND`, 401 → `UNAUTHENTICATED`, 403 → `FORBIDDEN` (retriable), 429 → `RATE_LIMITED` (retriable), else `INTERNAL`\n- Bitbucket `check_id` encodes `{pipeline_uuid}:{step_uuid}` — decode with brace-depth tracking (naive `split(':')` breaks on UUID braces); export `encodeBitbucketCheckId` / `decodeBitbucketCheckId` as testable helpers and cover the encode→decode round-trip in tests"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":621,"cache_read_input_tokens":53339,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":621},"output_tokens":62,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"4fc7e591-363a-4f04-935d-627d9ec34055"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TugpqoWbhJrK2XxM1qHt1n","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"800cddeb-724d-4f93-ab27-f55f0702c35d","timestamp":"2026-04-24T17:32:28.829Z","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","oldString":"- `TASK_DIR` is resolved by `ArtefactsService.buildCentralTaskDir()` and set in the Claude invocation env by `PhaseRouterService` — do not construct it independently in other services","newString":"- `TASK_DIR` is resolved by `ArtefactsService.buildCentralTaskDir()` and set in the Claude invocation env by `PhaseRouterService` — do not construct it independently in other services\n\n## MCP Dispatchers (SM-*)\n\n- Platform-specific dispatchers for a shared tool: use `registry.has()` + `registry.replaceHandler()` to wrap the existing handler — route by `repo_ref` platform prefix, fall through to captured handler for non-matching refs; never call `registry.register()` on an already-registered tool (clobbers the prior handler)\n- MCP tool errors use `{ ok: false, code: ErrorCode, message, retriable, platform }` — HTTP status map: 404 → `NOT_FOUND`, 401 → `UNAUTHENTICATED`, 403 → `FORBIDDEN` (retriable), 429 → `RATE_LIMITED` (retriable), else `INTERNAL`\n- Bitbucket `check_id` encodes `{pipeline_uuid}:{step_uuid}` — decode with brace-depth tracking (naive `split(':')` breaks on UUID braces); export `encodeBitbucketCheckId` / `decodeBitbucketCheckId` as testable helpers and cover the encode→decode round-trip in tests","originalFile":"# Agent Configuration — agent-core\n\n## Purpose\n\nThis is the core orchestration server for the AgentSDE pipeline. It is a production NestJS application. Changes here affect the live webhook handler and all downstream agent runs.\n\n## Skills Available\n\n- `/orchestrate` — used for all issue work (plan + implement + PR)\n- `/review-pr` — used when reviewing PRs on this repo\n\n## Workflow\n\n1. Read the issue carefully — understand the impact on the webhook pipeline\n2. Read the relevant source files before making changes\n3. Run `npm run test` locally to verify unit tests pass\n4. Run `npm run lint` — zero warnings are allowed\n5. Create branch: `feat/<issue-number>-<slug>` or `fix/<issue-number>-<slug>`\n6. Commit with message: `feat(<scope>): <what>` or `fix(<scope>): <what>`\n7. Open PR with `Closes #<issue-number>` in the body\n8. Do not merge your own PR — CI must pass first\n9. When adding a new module, env var, or dev command, update CLAUDE.md in the same PR — `.env.example` is the canonical env vars reference; `src/` modules should all appear in the Key modules list\n10. For documentation-only PRs (no TS/JS modified), steps 3–4 (test and lint) may be skipped — state this explicitly in the pre-commit checks comment\n\n## Task Phases\n\n1. `plan` — read the issue and relevant source files, identify the change\n2. `implement` — make the change, write or update unit tests\n3. `test` — run `npm run test` and `npm run lint`, fix any failures\n4. `review` — open PR, wait for human approval gate\n5. `deploy` — handled by CI on merge to master\n\n## Artefacts\n\nTask state is written to `AgentSDE/agent-core-artefacts` repo, branch `AgentSDE/agent-core/<issue-number>`.\n\n## Directive System\n\n- `ALLOWED_DIRECTIVES` in `directive.dto.ts` is the single source of truth — `DirectiveValue` union type auto-derives from it\n- New directives must be wired in **two places**: `DirectiveService.applyDirective()` (internal API path) and `PhaseRouterService.route()` (GitHub webhook path); missing either creates a silent gap\n- Transient directives (`retry`, `restart`) must **not** overwrite `task.directive` — preserve the task's execution directive (`auto`/`careful`/`quick`)\n- Always post a GitHub feedback comment when a directive is rejected (terminal state, live process, unrecognised) — never drop silently\n- When shipping a new directive or pipeline behavior, add its AGENTS.md documentation in the same PR — deferred documentation accumulates as separate cleanup issues\n- Before closing an open PR on replan/restart, always post a \"superseded\" comment explaining the closure — silent PR closes are flagged by reviewers\n- Each directive that resets task state should have its own dedicated reset method (`resetForReplan`, `resetForRestart`) with distinct semantics — avoid sharing or overloading a single method with conditional branching\n- `DirectiveService.applyDirective()` does not enforce actor-level authorization — that responsibility lives at the GitHub webhook layer. In tests, document this boundary with a `TODO` and a reference issue rather than asserting service-level rejection\n\n### `skip-quality-gate` Directive\n- One-time bypass (`/agentsde skip-quality-gate`, legacy: `/agent skip-quality-gate`) of pre-PR quality gate before deliver phase. Sets `task.qualityGateOverride = true`; consumed atomically at start of next deliver phase (reset to `false` before invocation — cannot carry over). Does not skip code review. Confirmation comment posted on set. **Source:** `directive.service.ts` + `phase-router.service.ts`.\n\n### `refine` Directive\n- Re-runs refine phase on a `gated` task. Rejected with a feedback comment if `task.status` is anything other than `gated` (terminal, active, blocked, paused). **Source:** `directive.service.ts` (`applyDirective`, refine branch).\n\n## Multi-Tenant Consumer Migration (Wave 2+)\n\nWhen migrating any service from `GitHubService` to an abstract provider (e.g., `PlatformProvider`), update three layers atomically: (1) swap `@Inject(PLATFORM_PROVIDER) platform: PlatformProvider` in the constructor (remove `GitHubService` import), (2) replace `GitHubModule` with `PlatformModule` in the module `imports` array, (3) switch the spec mock from `{ provide: GitHubService, useValue: {...} }` to `{ provide: PLATFORM_PROVIDER, useValue: {...} }` and update the retrieved mock type to `jest.Mocked<PlatformProvider>`. Methods typed as optional on the provider interface (e.g., `setProjectItemStatus`) must use `?.()` — this is correct and intentional, not a typing gap.\n\n- PRs that are part of Wave 2+ multi-tenant migration should target `rc/multi-tenant` as the base branch — PRs targeting `master` will be redirected by reviewers. Check whether this branch exists before opening the PR.\n- PRs that are part of the token usage tracking feature series (TU-*) should target `rc/token-usage` as the base branch, not `master` — same pattern as `rc/multi-tenant` for the multi-tenant wave. Token usage types live in `src/llm/llm-provider.interface.ts` (`UsageEntry`, `UsageData`); both `PhaseResult` and `SignalResult` carry `usage?: UsageData`.\n- PRs that are part of the release strategy feature series (RS-*) should target `rc/release-strategy` as the base branch, not `master` — same pattern as `rc/multi-tenant` and `rc/token-usage`; check whether this branch exists before opening the PR.\n- PRs that are part of the GitHub Apps feature series (GA-*) should target `rc/github-apps` as the base branch, not `master` — same pattern as `rc/multi-tenant`, `rc/token-usage`, and `rc/release-strategy`; check whether this branch exists before opening the PR. Same applies to Atlassian integration (BJ-*) → `rc/atlassian-integration`. Same applies to Skills MCP Platform (SM-*) → `rc/skills-mcp-platform`.\n- GitHub App auth: sign RS256 JWTs via Node.js `crypto.createSign('RSA-SHA256')` (no external JWT library); read PEM from disk path (not inline env var); use a promise-based lock so concurrent `getToken()` callers share one refresh promise rather than triggering N parallel API calls; refresh proactively when < 5 min remain on the cached installation token. `GitHubModule` registers a `useFactory` for `PLATFORM_AUTH_PROVIDER` that selects `GitHubAppAuthProvider` vs `GitHubPatAuthProvider` based on `GITHUB_APP_ID` presence, and exports the token — importing `GitHubModule` gives consumers both `GitHubService` and `PLATFORM_AUTH_PROVIDER` without additional setup. Call `await this.authProvider.getToken()` inline per request (not cached at construction) to support token refresh. `getBotUsername()` is synchronous and stable — cache it at construction as `private readonly botUsername` rather than calling per event handler; modules that did not previously import `GitHubModule` must add it to their `imports` to expose `PLATFORM_AUTH_PROVIDER`. When migrating static config vars (e.g., `GITHUB_TOKEN`, `AGENT_USERNAME`) to provider resolution, remove them from `configKeys` in `ClaudeInvocationService` — leftover static entries silently shadow the fresh provider values in the worker env.\n- `PR_BASE_BRANCH` in the Claude invocation env overrides the deliver-phase PR target branch — set by `PhaseRouterService.buildEnv()` via `ReleaseService.findActiveForTask(taskId)`; returns `{ rcBranch, releaseId, baseBranch, key, title, status, directive }` when a task is in an active release, `null` otherwise. `buildEnv()` is async for this reason.\n- When a Wave N dependency module (e.g., `PlatformModule`) is not yet merged, provide a minimal inline provider directly in the consuming module rather than importing the missing module — this unblocks Wave N+1 work without waiting for all dependencies to land; remove the stub once the real module is available.\n- `AtlassianOAuthAuthProvider` follows `GitHubAppAuthProvider`'s 5-min cache + promise concurrency-guard pattern with one critical addition: Atlassian rotates the refresh token on every call — persist via `stateRepo.saveRefreshToken` **before** returning the access token; a crash after refresh but before persistence leaves a consumed token that cannot be retried (permanent re-auth required).\n- BJ-* Atlassian platform modules (`JiraModule`, `BitbucketModule`) each export a platform-specific DI token (`JIRA_AUTH_PROVIDER`, `BITBUCKET_AUTH_PROVIDER`) — NOT the shared `PLATFORM_AUTH_PROVIDER`; root wiring into the generic token is BJ-11. Services that need the unified `PLATFORM_AUTH_PROVIDER` must wait for BJ-11; exception: `InvokeModule` imports platform modules with `@Optional()` and collects non-null providers into `PLATFORM_AUTH_PROVIDERS` (array token) — `ClaudeInvocationService.invoke()` selects by `provider.platform === env['PLATFORM']`, throws for unrecognised platforms, always stamps `env['PLATFORM']` on outgoing env, and does not overwrite caller-supplied values. Env keys per platform: github `{GITHUB_TOKEN, AGENT_USERNAME, GITHUB_OWNER, GITHUB_REPO, PROJECT_ID, PROJECT_NUMBER}`, bitbucket `{BITBUCKET_TOKEN, BITBUCKET_BOT_USERNAME, BITBUCKET_WORKSPACE, BITBUCKET_REPO}`, jira `{JIRA_TOKEN, JIRA_BOT_USERNAME, JIRA_SITE_URL, JIRA_CLOUD_ID, JIRA_PROJECT_KEY}`. Factory providers return `null` when the gateway env var (`*_OAUTH_CLIENT_ID`) is unset — consumers must type-guard before use; app boots cleanly without optional credentials. Define DI tokens in the service file and re-export from the module to prevent circular imports between service and module.\n- Jira REST v3 API conventions: comment/description bodies must be ADF-wrapped (`{ type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] }`); labels mutate via `update: { labels: [{ add: 'lbl' }] }` / `[{ remove: 'lbl' }]` on PUT `/rest/api/3/issue/{key}` — NOT `fields.labels` replacement; `setAssignee` uses a dedicated PUT endpoint `/assignee` with `{ accountId }`.\n- Reuse upstream injection tokens (e.g., `LLM_PROVIDER` from `llm-provider.interface.ts`) rather than defining a local duplicate — token mismatches cause silent DI failures that are hard to diagnose.\n\n## Testing Conventions\n\n- Never hardcode production paths (e.g., `/home/agent/AgentSDE/tasks/...`) in test files — CI machines don't have these directories; use `os.tmpdir()` or a `testTmpDir` fixture instead\n- `ClaudeInvocationService` parses stdout as JSON lines (stream-json format); test stubs must wrap signals in JSON objects, not raw strings\n- When multiple services derive the same filesystem path, centralize it in one helper (e.g., `ArtefactsService.buildCentralTaskDir()`) — prevents path drift across services\n- Prefer real temp dirs over filesystem mocks for I/O-heavy services — mocking `fs` in unit tests for services like `ClaudeInvocationService` raises reviewer concerns and can miss real I/O edge cases\n- When injecting a new shared service into multiple existing modules, update all three layers per consumer: (1) constructor injection in the service, (2) the consumer's module `imports` array (e.g., `TenantModule`), and (3) a mock provider in each `.spec.ts` — reviewers flag any missing layer as a DI breakage risk\n- When pre-existing test failures exist on `master` unrelated to the change, call them out explicitly in the PR body (e.g., \"6 pre-existing watchdog failures unchanged\") — reviewers cannot distinguish regressions from known failures without this\n- When adding a new pipeline status or phase transition, always cover three test scenarios: (1) idempotent re-entry (target state already set), (2) happy path, and (3) recovery path where the triggering condition resolves before the threshold — reviewers flag gaps in these as non-obvious but high-value coverage\n- When testing services that read env vars directly, save and restore original values in `beforeEach`/`afterEach` — unrestored env mutations (e.g., `STUCK_THRESHOLD_MS`, `SLOW_THRESHOLD_MS`) silently corrupt other test suites running in the same Jest worker process\n- For directive integration tests, wire `DirectiveService` with real `TaskStateService` + in-memory SQLite and mocked `GitHubService`/`ArtefactsService` — follow the `blocked-persistent-to-refine` pattern; each new directive spec should cover (1) happy path with open PR, (2) no-open-PR variant, and (3) terminal/active task rejection guard\n- A cancelled E2E CI job (step interrupted by infra timeout, not an assertion failure) is not a code regression — re-trigger E2E after the fix; if the timeout recurs, escalate as a CI infra issue rather than treating it as a test failure\n\n## Logging Conventions\n\n- `{taskDir}/meta/` is the canonical per-task log directory — already included in `ARTEFACT_SUBDIRS`; files written there are synced to `agent-core-artefacts` automatically without further config changes\n- When piping spawned-process stdout/stderr to a file write stream, open in append mode (`flags: 'a'`) and close the stream in both the `close` and `error` event handlers to avoid resource leaks\n- In cleanup methods that `unlink` files in a loop, only swallow `ENOENT` errors; re-throw or log all other errors — broad `catch` blocks in cleanup paths are reviewers' top flag for silent data-loss risk\n\n## Adding a New Pipeline Phase\n\nWhen adding a new SDLC phase, all of the following must be updated in the same PR:\n- `PHASE_ORDER` array and `phaseColumn()` map in `InternalAdapterService`\n- `PHASE_TO_SKILL` map in `ClaudeInvocationService`\n- New `phase<Name>` column on `TaskEntity` (with `DEFAULT 'pending'` for safe migration)\n- `phaseName: 'pending'` in `createTask()` defaults in `TaskStateService`\n- `onPhaseSkip` / `onPhaseComplete` hooks in `PhaseHooksService` if the phase needs lifecycle handling\n- New skill files under `.claude/skills/<phase>/` (SKILL.md + role.md); consider splitting DB schema changes into a separate PR — reviewers flag large pipeline additions as high-risk\n- `phaseKey()` static map in `TaskStateService` — do NOT rely on dynamic camelCase derivation; it produces `phaseAddress_review` instead of `phaseAddressReview` for underscore-named phases; add an explicit entry to the static map for every new phase\n- `setPhaseStatus(task, phase, status)` only mutates the in-memory entity — it does not persist to DB; to save the change, pass the field explicitly in the `updateTaskStatus()` call (e.g., `{ phaseReview: 'skipped' }`); entity mutation alone is silently discarded and will not survive the next DB read\n\n## Worktree Management\n\n- Use idempotent `getOrCreateWorktree()` instead of per-phase create/destroy: check if worktree is registered via `git worktree list --porcelain`, attach an existing branch without `-b`, or fresh-create — avoids `git branch -D` failures on checked-out branches that cause permanent stuck loops\n- Move worktree cleanup to terminal state (`complete`) in `InternalAdapterService`, not in `finally` blocks inside `PhaseRouterService` — per-phase cleanup destroys worktrees still needed by subsequent phases\n- Tasks that never reach `complete` (permanently blocked or abandoned) accumulate worktrees indefinitely — any cancellation or watchdog path that abandons a task must also call `cleanupWorktree()`\n- On worktree reuse, run only `git fetch origin` to update remote-tracking refs — never `git reset --hard origin/master`; the branch already holds agent commits from prior phases, and a hard reset silently destroys them\n\n## Gate & Merge\n\n- Use an explicit `GATE_BYPASS_EVENT_TYPES = new Set(['directive', 'review_approved', 'ci_passed', 'ci_failure'])` in `PhaseRouterService` — replacing a bare `event.type !== 'directive'` check prevents `review_approved` and CI events from re-gating an already-approved task\n- When a `review_approved` event arrives and the deliver phase is already complete, call `mergeIfReady()` directly — extract merge logic into a shared public method so both `onDeliverComplete` and the `review_approved` path reuse it without duplication\n- Every phase-completion code path must call `phaseHooks.onPhaseComplete(task, phase)` explicitly — hooks inside `executePhase` do not fire on alternative paths (e.g., `review_approved` clearing the gate); a missing call silently skips lifecycle effects such as auto-merge even when the phase status is correctly written\n\n## Release Strategy\n\nA release is a configuration override that groups tasks and routes their PRs to an RC branch instead of master.\n\n### Data Model\n\n3 tables: `releases`, `release_tasks`, `release_repo_prs`.\n\n### Pipeline Integration\n\nWhen a task belongs to an active release:\n- `PhaseRouterService.buildEnv()` sets `PR_BASE_BRANCH` to the RC branch\n- The deliver skill creates PRs targeting the RC branch (not master)\n- `findActiveForTask(taskId)` is the key lookup — queries `release_tasks` → `releases`\n\n- When a task belongs to an active release, pass `rcBranch` as `baseBranch` to `getOrCreateWorktree()` (default `'master'`) — omitting it creates worktrees from master, not the RC branch; fetch the release config once in `executePhase()` and thread it through to both `getOrCreateWorktree()` and `buildEnv()` to avoid a duplicate `findActiveForTask()` call.\n\n### Status Lifecycle\n\n`draft` → `active` → `staging` → `validating` → `validated` → `shipping` → `shipped`\n(`failed` can transition back to `active` for retry)\n\n## Watchdog\n\n- Query `['active', 'gated']` statuses in `WatchdogService` — tasks waiting indefinitely for deliver-gate approval are invisible with `active`-only queries and accumulate as silent stuck issues\n- Expose `POST /internal/watchdog/run` (returns `{ stuck, slow }`) for on-demand detection — avoids E2E tests waiting 5 minutes for the cron cycle; `runCheck()` should be a public method on the service\n\n## Test Environment\n\nA test instance of agent-core runs alongside production for E2E verification before merging to master.\n\n- **Test clone:** `/home/agent/AgentSDE/testing/agent-core` (branch: `testing`)\n- **Port:** 8777, managed by pm2 as `agent-core-test`\n- **Database:** `agent-core-test.db` (configured via `DB_NAME` env var, separate from production `agent-core.db`)\n- **Nginx:** `wh.kontashera.com/test/*` proxies to `localhost:8777`\n- **Webhook:** Repo-level webhook on `AgentSDE/agent-core-e2e` delivers to `wh.kontashera.com/test/webhooks/github`\n- Never modify the test instance while production E2E or nightly runs are in progress\n- Always restore `agent-core-e2e/.env` `AGENT_CORE_URL` back to `http://localhost:3000` after testing\n- The `testing` branch can be force-pushed freely — it is not protected\n\n## Database Entities\n\nWhen adding a new TypeORM entity, update four locations atomically:\n1. Create entity file under `src/database/entities/<name>.entity.ts`\n2. Export from `src/database/entities/index.ts` barrel\n3. Add to the `forRoot` entities array in `DatabaseModule`\n4. Add to the `forFeature` array in `DatabaseModule`\n\nFor SQLite: use `type: 'integer'`/`type: 'real'` for numeric columns (TypeORM defaults don't map correctly); use `@Column({ name: 'snake_case_name' })` for consistent snake_case DB column naming.\n- When a service needs entities from another domain (e.g., `TaskEntity` in `ReleaseModule`), add them to the local `TypeOrmModule.forFeature()` array — do NOT import the owning module; avoids circular dependency risks.\n- When removing a TypeORM entity, mirror the four-location add pattern in reverse: delete the entity file, remove its export from `entities/index.ts`, remove it from the `forRoot` entities array in `DatabaseModule`, and remove `forFeature` references in all consumer modules (e.g., `OperationalModule`, `ControlApiModule`) and their spec files.\n- Before inserting into a join/relation table, always query existing rows and filter them out — silently inserting duplicates breaks unique constraints and is hard to diagnose at runtime.\n- When an issue specifies Postgres-style schema changes (migration files, `uuid_generate_v4()`, `timestamptz`), translate to a TypeORM entity with SQLite-compatible types (`@PrimaryGeneratedColumn('uuid')`, `datetime`) — no migration tooling exists in this repo; `synchronize: true` is the schema mechanism. Verify the schema in `database.module.spec.ts` with in-memory SQLite covering uuid generation, unique-constraint enforcement, and cross-tenant reuse.\n\n## REST Controllers\n\n- Throw HTTP exceptions (`NotFoundException`, etc.) in the controller layer, not in the service — services should return `null`/`undefined` for \"not found\" cases; the controller converts these to the appropriate HTTP status using NestJS built-in exceptions.\n- Register new controllers in their owning module and import that module into `AppModule` in the same PR — a controller not imported into `AppModule` is silently unreachable.\n\n## Redis / BullMQ\n\n- Use a dedicated `ioredis` instance (`lazyConnect: true`) in `HealthModule` for `/health` Redis probes — never share BullMQ's internal connection; health checks coupled to queue lifecycle report misleading states during degraded scenarios\n- Register queues via `BullModule.forRootAsync()` (ConfigService-driven) + `BullModule.registerQueue()` in `AppModule` — this is the established queue registration pattern\n- Redis pub/sub requires two separate ioredis clients — one dedicated to publish (`pub`), one to subscribe (`sub`); a client in subscribe mode cannot also publish. Follow the `EventBusModule` factory pattern. `emit()` must swallow Redis errors; per-handler error isolation in `on()` prevents one failing handler from blocking others — event delivery must never block pipeline advancement\n- If the BullMQ class manages its own `IORedis` connections directly, do NOT also register `BullModule.forRootAsync` in the same feature module — it conflicts with `InvokeModule`'s existing registration; use direct `IORedis` instances instead.\n- **BullMQ stall-retry idempotency**: Wrap `createTask()` in `route()` with try/catch; on unique-constraint error fall back to `findByIssueAndRepo()` — concurrent deliveries share the existing row. Guard phase hooks (`onPhaseBlocked`, `onCompoundComplete`) with per-phase event records before posting comments; skip if already recorded. Add early-return in `executePhase()` if phase is already `'active'`. Extend phase-event dedup guard to skip when `phaseStatus === 'failed' && task.status === 'gated'` — persistent-block stalls differ from transient retries (which keep `status === 'active'`).\n\n## Config Schema\n\n- Use Joi `.when()` for conditional env var validation in two directions: (a) make existing required fields optional when a new auth mode flag is present (`Joi.string().when('FLAG', { is: Joi.exist(), then: Joi.optional(), otherwise: Joi.required() })`); (b) make companion vars required when an optional provider's gateway var is set (e.g., `*_OAUTH_CLIENT_ID`). Designate one gateway var per provider group — partial configs missing the gateway silently pass (intentional; mirrors `GITHUB_APP_ID` pattern).\n- Config schema tests for conditional validators must cover three cases: (1) empty/legacy config passes with no error, (2) gateway set with all required companions passes, (3) gateway set but companions missing fails and lists the missing vars — case (3) is the partial-fail guard that prevents silent misconfiguration.\n\n## Important constraints\n\n- This service runs live on the VPS via pm2 (`pm2 restart agent-core` to reload)\n- Port `3000` is proxied through nginx — do not change the port without updating nginx config\n- `GITHUB_WEBHOOK_SECRET` must match the secret configured in the GitHub org webhook\n- The WAL file path (`agent-core-artefacts/watchdog/wal.jsonl`) is shared with the watchdog — do not change its location without updating both services\n- `ClaudeInvocationService.invoke()` is now the primary entry point for agent runs; `dispatch-v2.sh`/`DispatchService` is legacy and being superseded by the `invoke` module\n- `GITHUB_WEBHOOK_SECRET` must be read with `get()` (not `getOrThrow()`) and verification skipped when unset — E2E environments deliberately omit the secret; `getOrThrow()` returns HTTP 500 instead of gracefully ignoring the event\n- `TASK_DIR` is resolved by `ArtefactsService.buildCentralTaskDir()` and set in the Claude invocation env by `PhaseRouterService` — do not construct it independently in other services\n","structuredPatch":[{"oldStart":192,"oldLines":3,"newStart":192,"newLines":9,"lines":[" - `ClaudeInvocationService.invoke()` is now the primary entry point for agent runs; `dispatch-v2.sh`/`DispatchService` is legacy and being superseded by the `invoke` module"," - `GITHUB_WEBHOOK_SECRET` must be read with `get()` (not `getOrThrow()`) and verification skipped when unset — E2E environments deliberately omit the secret; `getOrThrow()` returns HTTP 500 instead of gracefully ignoring the event"," - `TASK_DIR` is resolved by `ArtefactsService.buildCentralTaskDir()` and set in the Claude invocation env by `PhaseRouterService` — do not construct it independently in other services","+","+## MCP Dispatchers (SM-*)","+","+- Platform-specific dispatchers for a shared tool: use `registry.has()` + `registry.replaceHandler()` to wrap the existing handler — route by `repo_ref` platform prefix, fall through to captured handler for non-matching refs; never call `registry.register()` on an already-registered tool (clobbers the prior handler)","+- MCP tool errors use `{ ok: false, code: ErrorCode, message, retriable, platform }` — HTTP status map: 404 → `NOT_FOUND`, 401 → `UNAUTHENTICATED`, 403 → `FORBIDDEN` (retriable), 429 → `RATE_LIMITED` (retriable), else `INTERNAL`","+- Bitbucket `check_id` encodes `{pipeline_uuid}:{step_uuid}` — decode with brace-depth tracking (naive `split(':')` breaks on UUID braces); export `encodeBitbucketCheckId` / `decodeBitbucketCheckId` as testable helpers and cover the encode→decode round-trip in tests"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01Qffg9jkah4zCT9vuqfeffw","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DPvJqSEmvMHTtqRFAtTxZw","name":"Bash","input":{"command":"wc -l /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","description":"Verify final line count"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":547,"cache_read_input_tokens":53960,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":547},"output_tokens":58,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"5dd01764-bd9b-48bd-b06f-8f544ea4d42a"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DPvJqSEmvMHTtqRFAtTxZw","type":"tool_result","content":"200 /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","is_error":false}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"4e287484-8528-4f0a-89b3-d6e91fad5d84","timestamp":"2026-04-24T17:32:30.779Z","tool_use_result":{"stdout":"200 /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md/AGENTS.md","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01WBcm51Jf2X2NbsjfeMvQCY","type":"message","role":"assistant","content":[{"type":"text","text":"Exactly 200 lines. Now commit, push, and create the PR."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":261,"cache_read_input_tokens":54507,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":261},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"f0d1834d-fc4a-4809-9654-762ec816c76b"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01WBcm51Jf2X2NbsjfeMvQCY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01GhPJRRgS9vrc3PshX1cfXb","name":"Bash","input":{"command":"cd /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md && git add AGENTS.md && git commit -m \"docs: update AGENTS.md with learnings from #640\"","description":"Commit AGENTS.md changes"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":261,"cache_read_input_tokens":54507,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":261},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"70ecd358-89e3-440b-a8fd-70f08624ac95"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01GhPJRRgS9vrc3PshX1cfXb","type":"tool_result","content":"[agents-md/issue-640 7e9b91f] docs: update AGENTS.md with learnings from #640\n 1 file changed, 7 insertions(+), 7 deletions(-)\nShell cwd was reset to /home/agent/AgentSDE/agent-core","is_error":false}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"675683e2-7d46-4637-b902-dd75c97672f7","timestamp":"2026-04-24T17:32:33.755Z","tool_use_result":{"stdout":"[agents-md/issue-640 7e9b91f] docs: update AGENTS.md with learnings from #640\n 1 file changed, 7 insertions(+), 7 deletions(-)","stderr":"\nShell cwd was reset to /home/agent/AgentSDE/agent-core","interrupted":false,"isImage":false,"noOutputExpected":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_019kkDQCQzRmo5GNM9aK8j2a","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XitkW549eLczyCtn9XSTSN","name":"Bash","input":{"command":"cd /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md && git push -u origin agents-md/issue-640 2>&1","description":"Push branch to origin"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":231,"cache_read_input_tokens":54768,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":231},"output_tokens":63,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"2f15c254-a2f9-4ab2-88d8-ada618e6e326"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XitkW549eLczyCtn9XSTSN","type":"tool_result","content":"remote: \nremote: Create a pull request for 'agents-md/issue-640' on GitHub by visiting: \nremote: https://github.com/AgentSDE/agent-core/pull/new/agents-md/issue-640 \nremote: \nTo https://github.com/AgentSDE/agent-core.git\n * [new branch] agents-md/issue-640 -> agents-md/issue-640\nbranch 'agents-md/issue-640' set up to track 'origin/agents-md/issue-640'.\nShell cwd was reset to /home/agent/AgentSDE/agent-core","is_error":false}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"e02c2c05-545d-4b9e-b94c-eb1dad0d5152","timestamp":"2026-04-24T17:32:37.515Z","tool_use_result":{"stdout":"remote: \nremote: Create a pull request for 'agents-md/issue-640' on GitHub by visiting: \nremote: https://github.com/AgentSDE/agent-core/pull/new/agents-md/issue-640 \nremote: \nTo https://github.com/AgentSDE/agent-core.git\n * [new branch] agents-md/issue-640 -> agents-md/issue-640\nbranch 'agents-md/issue-640' set up to track 'origin/agents-md/issue-640'.","stderr":"\nShell cwd was reset to /home/agent/AgentSDE/agent-core","interrupted":false,"isImage":false,"noOutputExpected":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01XsKDFJef3jxxd23WyMqoz5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HL2FxXhWeAdvti1xtavBkJ","name":"Bash","input":{"command":"cd /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md && EXISTING_PR=\"$(gh pr view --json url -q '.url' 2>/dev/null || true)\" && if [[ -z \"$EXISTING_PR\" ]]; then ISSUE_TITLE=\"$(gh issue view 640 --json title -q '.title')\"; gh pr create --title \"docs: update #640 — ${ISSUE_TITLE}\" --body \"Automated knowledge base update from compound phase of issue #640.\" --base master; else echo \"Existing PR: $EXISTING_PR\"; fi","description":"Create PR for AGENTS.md update"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":282,"cache_read_input_tokens":54999,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":282},"output_tokens":61,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"3f76eb25-7c67-4caa-8dbc-e774991bfc71"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HL2FxXhWeAdvti1xtavBkJ","type":"tool_result","content":"https://github.com/AgentSDE/agent-core/pull/683\nShell cwd was reset to /home/agent/AgentSDE/agent-core","is_error":false}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"7897284b-287f-4db8-86b8-7ba5d57673be","timestamp":"2026-04-24T17:32:44.353Z","tool_use_result":{"stdout":"https://github.com/AgentSDE/agent-core/pull/683","stderr":"\nShell cwd was reset to /home/agent/AgentSDE/agent-core","interrupted":false,"isImage":false,"noOutputExpected":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_018Z1NffQBD8YvJmHepBZFfv","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01D4yHr81LaTT3cFBAaPhA3P","name":"Bash","input":{"command":"cd /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-640-agents-md && gh pr merge 683 --squash --delete-branch --auto 2>/dev/null || true && git checkout - 2>/dev/null || true","description":"Enable auto-merge on PR and switch back"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":273,"cache_read_input_tokens":55281,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":273},"output_tokens":64,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"bf8b415f-b7dc-4256-9950-2a7d5f55fd8d"}
{"type":"system","subtype":"task_started","task_id":"bh0vxcwjt","tool_use_id":"toolu_01D4yHr81LaTT3cFBAaPhA3P","description":"Enable auto-merge on PR and switch back","task_type":"local_bash","uuid":"91fb551b-3edc-41c8-8d27-6f10740e4b67","session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0"}
{"type":"system","subtype":"task_notification","task_id":"bh0vxcwjt","tool_use_id":"toolu_01D4yHr81LaTT3cFBAaPhA3P","status":"completed","output_file":"","summary":"Enable auto-merge on PR and switch back","uuid":"9d3d9b7f-f87f-40e6-883e-c25ef20cb756","session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01D4yHr81LaTT3cFBAaPhA3P","type":"tool_result","content":"Shell cwd was reset to /home/agent/AgentSDE/agent-core","is_error":false}]},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"8418619d-57e0-4d1b-b0b8-2084b816135e","timestamp":"2026-04-24T17:32:50.806Z","tool_use_result":{"stdout":"","stderr":"\nShell cwd was reset to /home/agent/AgentSDE/agent-core","interrupted":false,"isImage":false,"noOutputExpected":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01R9KW2M5jtbSwC9CM8CjPDm","type":"message","role":"assistant","content":[{"type":"text","text":"PR #683 is created at `https://github.com/AgentSDE/agent-core/pull/683` with auto-merge enabled.\n\n**Summary:** Added an `## MCP Dispatchers (SM-*)` section to AGENTS.md with 3 reusable patterns from issue #640 (Bitbucket dispatcher for `v1.check_run_logs`):\n1. Use `registry.replaceHandler()` to layer platform-specific routing on top of existing tool registrations — never re-register an existing tool\n2. Consistent error envelope convention (`{ ok, code, message, retriable, platform }`) with HTTP status → error code mapping\n3. Bitbucket `check_id` encoding (`{pipeline_uuid}:{step_uuid}`) requires brace-depth-aware decoding, not naive `split(':')`\n\nTrimmed 6 lines of TU-/RS-specific implementation details to stay at exactly 200 lines.\n\n<promise>COMPLETE</promise>"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":173,"cache_read_input_tokens":55554,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":173},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","uuid":"1480b2b7-07e6-4ae4-a1f5-ef492e6fc9da"}
{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":281048,"duration_api_ms":272222,"num_turns":26,"result":"PR #683 is created at `https://github.com/AgentSDE/agent-core/pull/683` with auto-merge enabled.\n\n**Summary:** Added an `## MCP Dispatchers (SM-*)` section to AGENTS.md with 3 reusable patterns from issue #640 (Bitbucket dispatcher for `v1.check_run_logs`):\n1. Use `registry.replaceHandler()` to layer platform-specific routing on top of existing tool registrations — never re-register an existing tool\n2. Consistent error envelope convention (`{ ok, code, message, retriable, platform }`) with HTTP status → error code mapping\n3. Bitbucket `check_id` encoding (`{pipeline_uuid}:{step_uuid}`) requires brace-depth-aware decoding, not naive `split(':')`\n\nTrimmed 6 lines of TU-/RS-specific implementation details to stay at exactly 200 lines.\n\n<promise>COMPLETE</promise>","stop_reason":"end_turn","session_id":"01a9057f-9cdb-4c2b-a5d2-cdcd1b8915d0","total_cost_usd":0.66540905,"usage":{"input_tokens":21,"cache_creation_input_tokens":44951,"cache_read_input_tokens":905976,"output_tokens":14961,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":44951,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[{"input_tokens":1,"output_tokens":231,"cache_read_input_tokens":55554,"cache_creation_input_tokens":173,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":173},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":477,"outputTokens":19,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.000572,"contextWindow":200000,"maxOutputTokens":32000},"claude-sonnet-4-6":{"inputTokens":21,"outputTokens":14961,"cacheReadInputTokens":905976,"cacheCreationInputTokens":44951,"webSearchRequests":0,"costUSD":0.6648370499999999,"contextWindow":600000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"e3af7e26-1508-4c7c-bd74-7ce5bfd949c8"}