AI Agents SDE Task Viewer
  1. Home
  2. AgentSDE
  3. agent-core
  4. gh-501
  5. meta
  6. ai-output.jsonl
ai-output.jsonl(1.9 MB)· Apr 14, 2026
{"type":"system","subtype":"init","cwd":"/home/agent/AgentSDE/agent-core","session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","tools":["Task","TaskOutput","Bash","Glob","Grep","ExitPlanMode","Read","Edit","Write","NotebookEdit","WebFetch","TodoWrite","WebSearch","TaskStop","AskUserQuestion","Skill","EnterPlanMode","EnterWorktree","ExitWorktree","CronCreate","CronDelete","CronList","ToolSearch","mcp__claude_ai_Slack__slack_send_message","mcp__claude_ai_Slack__slack_schedule_message","mcp__claude_ai_Slack__slack_create_canvas","mcp__claude_ai_Slack__slack_update_canvas","mcp__claude_ai_Slack__slack_search_public","mcp__claude_ai_Slack__slack_search_public_and_private","mcp__claude_ai_Slack__slack_search_channels","mcp__claude_ai_Slack__slack_search_users","mcp__claude_ai_Slack__slack_read_channel","mcp__claude_ai_Slack__slack_read_thread","mcp__claude_ai_Slack__slack_read_canvas","mcp__claude_ai_Slack__slack_read_user_profile","mcp__claude_ai_Slack__slack_send_message_draft","ListMcpResourcesTool","ReadMcpResourceTool","mcp__claude_ai_Atlassian__atlassianUserInfo","mcp__claude_ai_Atlassian__getAccessibleAtlassianResources","mcp__claude_ai_Atlassian__getConfluencePage","mcp__claude_ai_Atlassian__searchConfluenceUsingCql","mcp__claude_ai_Atlassian__getConfluenceSpaces","mcp__claude_ai_Atlassian__getPagesInConfluenceSpace","mcp__claude_ai_Atlassian__getConfluencePageFooterComments","mcp__claude_ai_Atlassian__getConfluencePageInlineComments","mcp__claude_ai_Atlassian__getConfluenceCommentChildren","mcp__claude_ai_Atlassian__getConfluencePageDescendants","mcp__claude_ai_Atlassian__createConfluencePage","mcp__claude_ai_Atlassian__updateConfluencePage","mcp__claude_ai_Atlassian__createConfluenceFooterComment","mcp__claude_ai_Atlassian__createConfluenceInlineComment","mcp__claude_ai_Atlassian__getJiraIssue","mcp__claude_ai_Atlassian__editJiraIssue","mcp__claude_ai_Atlassian__createJiraIssue","mcp__claude_ai_Atlassian__getTransitionsForJiraIssue","mcp__claude_ai_Atlassian__getJiraIssueRemoteIssueLinks","mcp__claude_ai_Atlassian__getVisibleJiraProjects","mcp__claude_ai_Atlassian__getJiraProjectIssueTypesMetadata","mcp__claude_ai_Atlassian__getJiraIssueTypeMetaWithFields","mcp__claude_ai_Atlassian__addCommentToJiraIssue","mcp__claude_ai_Atlassian__transitionJiraIssue","mcp__claude_ai_Atlassian__searchJiraIssuesUsingJql","mcp__claude_ai_Atlassian__lookupJiraAccountId","mcp__claude_ai_Atlassian__addWorklogToJiraIssue","mcp__claude_ai_Atlassian__getIssueLinkTypes","mcp__claude_ai_Atlassian__createIssueLink","mcp__claude_ai_Atlassian__searchAtlassian","mcp__claude_ai_Atlassian__fetchAtlassian"],"mcp_servers":[{"name":"claude.ai Google Calendar","status":"needs-auth"},{"name":"claude.ai Gmail","status":"needs-auth"},{"name":"claude.ai Slack","status":"connected"},{"name":"claude.ai Atlassian","status":"connected"}],"model":"claude-sonnet-4-6","permissionMode":"default","slash_commands":["debug","simplify","batch","loop","claude-api","ar-e2e-cleanup","ar-ops-sweep","ar-pm-sweep","ar-tech-sweep","e2e-audit","address-review","compound-learnings","deliver-implementation","evaluate-issues","implement-code","investigate-and-fix","plan-and-propose","plan-task","product-manager","reply-to-issue-comment","reply-to-pr-comment","review-code","review-document","review-pr","scan-codebase","summarize-document","write-tests","ar-e2e-cleanup","e2e-audit","ar-ops-sweep","ar-pm-sweep","openclaw","ar-tech-sweep","compact","context","cost","heapdump","init","pr-comments","release-notes","review","security-review","extra-usage","insights"],"apiKeySource":"none","claude_code_version":"2.1.76","output_style":"default","agents":["general-purpose","statusline-setup","Explore","Plan"],"skills":["debug","simplify","batch","loop","claude-api","ar-e2e-cleanup","ar-ops-sweep","ar-pm-sweep","ar-tech-sweep","e2e-audit","address-review","compound-learnings","deliver-implementation","evaluate-issues","implement-code","investigate-and-fix","plan-and-propose","plan-task","product-manager","reply-to-issue-comment","reply-to-pr-comment","review-code","review-document","review-pr","scan-codebase","summarize-document","write-tests"],"plugins":[],"uuid":"71eca611-10b1-4378-a915-668ac838d836","fast_mode_state":"off"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01HmEWaFsbBkfVwKinFca7tS","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Let me start by reading the plan and context files from the task directory.","signature":"EogCClsIDBgCKkA8RwsaZprXLtXqupmRGuTynkxRcobTdg41UFpuNe68VnoVOKsESOsj2hcg1+pq9nVtgyen2RuEAWF9m2atGCdGMhFjbGF1ZGUtc29ubmV0LTQtNjgAEgx7IjwTXmaUZKOHMFcaDI+WhyznrRZB1vL/QSIwt1MzzE06f70v9HjaocXDvGNzfT2VKNTsCVYXmGa9mDcVtiMnxBuauEpl2F0kgnHBKluZhRBkmUP/nCy+j8/zIMB10fw9vvUjhbfAKLAXla5Rg9ZFT3/6EdDpuynkMV5OYFzDpFA2VUBz3mAFwl9LsyLC6Tgjpd5/b+c2y2t+DOAanfV8HUxuV3T8rQrQGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":8567,"cache_read_input_tokens":14164,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":8567},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"ec9f16aa-09b6-47db-8180-8aee81c0c1c7"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01HmEWaFsbBkfVwKinFca7tS","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01LrawbWm7NbKgMo8yf4oWMS","name":"Read","input":{"file_path":"/home/agent/AgentSDE/tasks/production/AgentSDE/agent-core/gh-501/plan/plan.md"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":8567,"cache_read_input_tokens":14164,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":8567},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"8b4d082a-3b90-42f7-a421-5b79426b233f"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01LrawbWm7NbKgMo8yf4oWMS","type":"tool_result","content":"     1→# Plan: Remove `execute` Phase and Align CC APIs with Current Pipeline\n     2→\n     3→## Summary\n     4→\n     5→Remove the obsolete `execute` phase from the database entity, DTOs, task creation defaults, and all test fixtures. The `PHASE_ORDER` and routing maps already exclude `execute` and already order `deliver` before `review` — this plan addresses the remaining inconsistencies in the entity, API validation, and test layer.\n     6→\n     7→## Files\n     8→\n     9→| File | Action | Description |\n    10→|------|--------|-------------|\n    11→| `src/database/entities/task.entity.ts` | modify | Remove `phaseExecute` column definition |\n    12→| `src/task-state/task-state.service.ts` | modify | Remove `phaseExecute: 'pending'` from `createTask()` defaults |\n    13→| `src/control-api/dto/phase-override.dto.ts` | modify | Remove `'execute'` from PHASES array, reorder to deliver-before-review |\n    14→| `src/control-api/dto/status-override.dto.ts` | modify | Remove `'execute'` from PHASES array, reorder to deliver-before-review |\n    15→| `src/database/database.module.spec.ts` | modify | Remove `phaseExecute` assertion |\n    16→| `src/task-state/task-state.service.spec.ts` | modify | Remove `phaseExecute` assertion from createTask test |\n    17→| `src/phase-router/phase-router.service.spec.ts` | modify | Replace all `'execute'` phase references with `'deliver'` in test fixtures |\n    18→| `src/directive/directive.service.spec.ts` | modify | Replace `phaseExecute` and `currentPhase: 'execute'` in test fixtures |\n    19→| `src/hooks/phase-hooks.service.spec.ts` | modify | Replace `'execute'` phase references in test cases |\n    20→| `src/hooks/compound.service.spec.ts` | modify | Replace `execute_completed`/`execute_failed` event types in test data |\n    21→| `src/watchdog/watchdog.service.spec.ts` | modify | Replace `currentPhase: 'execute'` in test fixtures |\n    22→| `src/invoke/claude-invocation.service.spec.ts` | modify | Replace `'execute'` in test case |\n    23→| `src/metrics/metrics.service.spec.ts` | modify | Replace `'execute'` in test case |\n    24→\n    25→## Steps\n    26→\n    27→1. **Remove `phaseExecute` from production code:** Delete the `phaseExecute` column from `TaskEntity`, remove it from `createTask()` defaults, remove `'execute'` from both DTO PHASES arrays, and reorder DTOs to `[refine, plan, deliver, review, compound]`.\n    28→2. **Update all test fixtures:** Replace every `phaseExecute` property and `currentPhase: 'execute'` reference across all `.spec.ts` files with valid phases (primarily `'deliver'`). Update event type strings like `execute_completed` → `deliver_completed`.\n    29→3. **Run lint and tests:** Execute `npm run lint` and `npm run test` to verify no remaining references to `execute` and all tests pass.\n    30→\n    31→## Verification\n    32→\n    33→- `grep -r 'execute' src/ --include='*.ts'` returns zero hits for `execute` as a pipeline phase (only hits for the verb \"execute\" in method names/comments are acceptable)\n    34→- `npm run test` passes with zero failures\n    35→- `npm run lint` passes with zero warnings\n    36→\n    37→## Risks\n    38→\n    39→- **Existing DB rows with `phase_execute` column:** SQLite ignores unmapped columns — TypeORM will stop reading/writing `phase_execute` but the column persists harmlessly. No destructive migration needed.\n    40→- **API consumers relying on `execute` phase in responses:** Any external tooling querying task records will no longer see `phaseExecute` in API responses. This is the intended behavior per the issue.\n    41→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"944f4d0d-8856-41ef-9be8-afdb3a3d09d9","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/AgentSDE/tasks/production/AgentSDE/agent-core/gh-501/plan/plan.md","content":"# Plan: Remove `execute` Phase and Align CC APIs with Current Pipeline\n\n## Summary\n\nRemove the obsolete `execute` phase from the database entity, DTOs, task creation defaults, and all test fixtures. The `PHASE_ORDER` and routing maps already exclude `execute` and already order `deliver` before `review` — this plan addresses the remaining inconsistencies in the entity, API validation, and test layer.\n\n## Files\n\n| File | Action | Description |\n|------|--------|-------------|\n| `src/database/entities/task.entity.ts` | modify | Remove `phaseExecute` column definition |\n| `src/task-state/task-state.service.ts` | modify | Remove `phaseExecute: 'pending'` from `createTask()` defaults |\n| `src/control-api/dto/phase-override.dto.ts` | modify | Remove `'execute'` from PHASES array, reorder to deliver-before-review |\n| `src/control-api/dto/status-override.dto.ts` | modify | Remove `'execute'` from PHASES array, reorder to deliver-before-review |\n| `src/database/database.module.spec.ts` | modify | Remove `phaseExecute` assertion |\n| `src/task-state/task-state.service.spec.ts` | modify | Remove `phaseExecute` assertion from createTask test |\n| `src/phase-router/phase-router.service.spec.ts` | modify | Replace all `'execute'` phase references with `'deliver'` in test fixtures |\n| `src/directive/directive.service.spec.ts` | modify | Replace `phaseExecute` and `currentPhase: 'execute'` in test fixtures |\n| `src/hooks/phase-hooks.service.spec.ts` | modify | Replace `'execute'` phase references in test cases |\n| `src/hooks/compound.service.spec.ts` | modify | Replace `execute_completed`/`execute_failed` event types in test data |\n| `src/watchdog/watchdog.service.spec.ts` | modify | Replace `currentPhase: 'execute'` in test fixtures |\n| `src/invoke/claude-invocation.service.spec.ts` | modify | Replace `'execute'` in test case |\n| `src/metrics/metrics.service.spec.ts` | modify | Replace `'execute'` in test case |\n\n## Steps\n\n1. **Remove `phaseExecute` from production code:** Delete the `phaseExecute` column from `TaskEntity`, remove it from `createTask()` defaults, remove `'execute'` from both DTO PHASES arrays, and reorder DTOs to `[refine, plan, deliver, review, compound]`.\n2. **Update all test fixtures:** Replace every `phaseExecute` property and `currentPhase: 'execute'` reference across all `.spec.ts` files with valid phases (primarily `'deliver'`). Update event type strings like `execute_completed` → `deliver_completed`.\n3. **Run lint and tests:** Execute `npm run lint` and `npm run test` to verify no remaining references to `execute` and all tests pass.\n\n## Verification\n\n- `grep -r 'execute' src/ --include='*.ts'` returns zero hits for `execute` as a pipeline phase (only hits for the verb \"execute\" in method names/comments are acceptable)\n- `npm run test` passes with zero failures\n- `npm run lint` passes with zero warnings\n\n## Risks\n\n- **Existing DB rows with `phase_execute` column:** SQLite ignores unmapped columns — TypeORM will stop reading/writing `phase_execute` but the column persists harmlessly. No destructive migration needed.\n- **API consumers relying on `execute` phase in responses:** Any external tooling querying task records will no longer see `phaseExecute` in API responses. This is the intended behavior per the issue.\n","numLines":41,"startLine":1,"totalLines":41}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01HmEWaFsbBkfVwKinFca7tS","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011PKUdairfYpnYxv5feohFN","name":"Read","input":{"file_path":"/home/agent/AgentSDE/tasks/production/AgentSDE/agent-core/gh-501/plan/context.json"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":8567,"cache_read_input_tokens":14164,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":8567},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"2b35b9a4-9355-4174-afbe-a054aff56c85"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011PKUdairfYpnYxv5feohFN","type":"tool_result","content":"     1→{\n     2→  \"meta\": {\n     3→    \"agent\": \"planner\",\n     4→    \"task_id\": \"501\",\n     5→    \"title\": \"Remove execute phase and align CC APIs with current pipeline\",\n     6→    \"created_at\": \"2026-04-14T12:00:00Z\"\n     7→  },\n     8→  \"inputs\": [\n     9→    { \"name\": \"GitHub Issue #501\", \"type\": \"context\", \"ref\": \"https://github.com/AgentSDE/agent-core/issues/501\", \"notes\": \"Remove execute phase, reorder deliver before review in DTOs\" },\n    10→    { \"name\": \"AGENTS.md\", \"type\": \"context\", \"ref\": \"AGENTS.md\", \"notes\": \"Pipeline phase documentation and conventions\" }\n    11→  ],\n    12→  \"outputs\": [\n    13→    { \"name\": \"plan.md\", \"type\": \"plan\", \"format\": \"md\", \"content\": \"Implementation plan for removing execute phase\" }\n    14→  ],\n    15→  \"files\": [\n    16→    { \"path\": \"src/database/entities/task.entity.ts\", \"action\": \"modify\", \"reason\": \"Remove phaseExecute column\" },\n    17→    { \"path\": \"src/task-state/task-state.service.ts\", \"action\": \"modify\", \"reason\": \"Remove phaseExecute from createTask defaults\" },\n    18→    { \"path\": \"src/control-api/dto/phase-override.dto.ts\", \"action\": \"modify\", \"reason\": \"Remove execute from PHASES, reorder\" },\n    19→    { \"path\": \"src/control-api/dto/status-override.dto.ts\", \"action\": \"modify\", \"reason\": \"Remove execute from PHASES, reorder\" },\n    20→    { \"path\": \"src/database/database.module.spec.ts\", \"action\": \"modify\", \"reason\": \"Remove phaseExecute assertion\" },\n    21→    { \"path\": \"src/task-state/task-state.service.spec.ts\", \"action\": \"modify\", \"reason\": \"Remove phaseExecute assertion\" },\n    22→    { \"path\": \"src/phase-router/phase-router.service.spec.ts\", \"action\": \"modify\", \"reason\": \"Replace execute references in fixtures\" },\n    23→    { \"path\": \"src/directive/directive.service.spec.ts\", \"action\": \"modify\", \"reason\": \"Replace execute references in fixtures\" },\n    24→    { \"path\": \"src/hooks/phase-hooks.service.spec.ts\", \"action\": \"modify\", \"reason\": \"Replace execute references in test cases\" },\n    25→    { \"path\": \"src/hooks/compound.service.spec.ts\", \"action\": \"modify\", \"reason\": \"Replace execute event types in test data\" },\n    26→    { \"path\": \"src/watchdog/watchdog.service.spec.ts\", \"action\": \"modify\", \"reason\": \"Replace execute references in fixtures\" },\n    27→    { \"path\": \"src/invoke/claude-invocation.service.spec.ts\", \"action\": \"modify\", \"reason\": \"Replace execute in test case\" },\n    28→    { \"path\": \"src/metrics/metrics.service.spec.ts\", \"action\": \"modify\", \"reason\": \"Replace execute in test case\" }\n    29→  ],\n    30→  \"steps\": [\n    31→    {\n    32→      \"id\": \"S1\",\n    33→      \"summary\": \"Remove phaseExecute from production code (entity, DTOs, task-state service)\",\n    34→      \"acceptance\": [\n    35→        \"phaseExecute column removed from TaskEntity\",\n    36→        \"phaseExecute removed from createTask() defaults\",\n    37→        \"execute removed from PHASES in phase-override.dto.ts and status-override.dto.ts\",\n    38→        \"DTO PHASES arrays ordered as [refine, plan, deliver, review, compound]\"\n    39→      ],\n    40→      \"depends_on\": []\n    41→    },\n    42→    {\n    43→      \"id\": \"S2\",\n    44→      \"summary\": \"Update all test files to replace execute phase references with valid phases\",\n    45→      \"acceptance\": [\n    46→        \"No test file references execute as a pipeline phase\",\n    47→        \"All phaseExecute property references removed from test fixtures\",\n    48→        \"Event types like execute_completed replaced with deliver_completed\",\n    49→        \"npm run test passes with zero failures\"\n    50→      ],\n    51→      \"depends_on\": [\"S1\"]\n    52→    },\n    53→    {\n    54→      \"id\": \"S3\",\n    55→      \"summary\": \"Run lint and full test suite to verify clean codebase\",\n    56→      \"acceptance\": [\n    57→        \"npm run lint passes with zero warnings\",\n    58→        \"npm run test passes with zero failures\",\n    59→        \"grep confirms no remaining execute phase references in src/\"\n    60→      ],\n    61→      \"depends_on\": [\"S2\"]\n    62→    }\n    63→  ],\n    64→  \"risks\": [\n    65→    { \"risk\": \"Existing DB rows retain phase_execute column data\", \"mitigation\": \"SQLite ignores unmapped columns; no destructive migration needed\" },\n    66→    { \"risk\": \"External API consumers depend on phaseExecute field\", \"mitigation\": \"Intended breaking change per issue requirements\" }\n    67→  ],\n    68→  \"assumptions\": [\n    69→    \"PHASE_ORDER and phaseColumn/phaseKey maps already exclude execute — confirmed by codebase audit\",\n    70→    \"TypeORM synchronize or schema drift handles the orphaned DB column safely\"\n    71→  ],\n    72→  \"open_questions\": []\n    73→}\n    74→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"a08cc5c1-cdc5-4659-ab01-8f15941459bc","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/AgentSDE/tasks/production/AgentSDE/agent-core/gh-501/plan/context.json","content":"{\n  \"meta\": {\n    \"agent\": \"planner\",\n    \"task_id\": \"501\",\n    \"title\": \"Remove execute phase and align CC APIs with current pipeline\",\n    \"created_at\": \"2026-04-14T12:00:00Z\"\n  },\n  \"inputs\": [\n    { \"name\": \"GitHub Issue #501\", \"type\": \"context\", \"ref\": \"https://github.com/AgentSDE/agent-core/issues/501\", \"notes\": \"Remove execute phase, reorder deliver before review in DTOs\" },\n    { \"name\": \"AGENTS.md\", \"type\": \"context\", \"ref\": \"AGENTS.md\", \"notes\": \"Pipeline phase documentation and conventions\" }\n  ],\n  \"outputs\": [\n    { \"name\": \"plan.md\", \"type\": \"plan\", \"format\": \"md\", \"content\": \"Implementation plan for removing execute phase\" }\n  ],\n  \"files\": [\n    { \"path\": \"src/database/entities/task.entity.ts\", \"action\": \"modify\", \"reason\": \"Remove phaseExecute column\" },\n    { \"path\": \"src/task-state/task-state.service.ts\", \"action\": \"modify\", \"reason\": \"Remove phaseExecute from createTask defaults\" },\n    { \"path\": \"src/control-api/dto/phase-override.dto.ts\", \"action\": \"modify\", \"reason\": \"Remove execute from PHASES, reorder\" },\n    { \"path\": \"src/control-api/dto/status-override.dto.ts\", \"action\": \"modify\", \"reason\": \"Remove execute from PHASES, reorder\" },\n    { \"path\": \"src/database/database.module.spec.ts\", \"action\": \"modify\", \"reason\": \"Remove phaseExecute assertion\" },\n    { \"path\": \"src/task-state/task-state.service.spec.ts\", \"action\": \"modify\", \"reason\": \"Remove phaseExecute assertion\" },\n    { \"path\": \"src/phase-router/phase-router.service.spec.ts\", \"action\": \"modify\", \"reason\": \"Replace execute references in fixtures\" },\n    { \"path\": \"src/directive/directive.service.spec.ts\", \"action\": \"modify\", \"reason\": \"Replace execute references in fixtures\" },\n    { \"path\": \"src/hooks/phase-hooks.service.spec.ts\", \"action\": \"modify\", \"reason\": \"Replace execute references in test cases\" },\n    { \"path\": \"src/hooks/compound.service.spec.ts\", \"action\": \"modify\", \"reason\": \"Replace execute event types in test data\" },\n    { \"path\": \"src/watchdog/watchdog.service.spec.ts\", \"action\": \"modify\", \"reason\": \"Replace execute references in fixtures\" },\n    { \"path\": \"src/invoke/claude-invocation.service.spec.ts\", \"action\": \"modify\", \"reason\": \"Replace execute in test case\" },\n    { \"path\": \"src/metrics/metrics.service.spec.ts\", \"action\": \"modify\", \"reason\": \"Replace execute in test case\" }\n  ],\n  \"steps\": [\n    {\n      \"id\": \"S1\",\n      \"summary\": \"Remove phaseExecute from production code (entity, DTOs, task-state service)\",\n      \"acceptance\": [\n        \"phaseExecute column removed from TaskEntity\",\n        \"phaseExecute removed from createTask() defaults\",\n        \"execute removed from PHASES in phase-override.dto.ts and status-override.dto.ts\",\n        \"DTO PHASES arrays ordered as [refine, plan, deliver, review, compound]\"\n      ],\n      \"depends_on\": []\n    },\n    {\n      \"id\": \"S2\",\n      \"summary\": \"Update all test files to replace execute phase references with valid phases\",\n      \"acceptance\": [\n        \"No test file references execute as a pipeline phase\",\n        \"All phaseExecute property references removed from test fixtures\",\n        \"Event types like execute_completed replaced with deliver_completed\",\n        \"npm run test passes with zero failures\"\n      ],\n      \"depends_on\": [\"S1\"]\n    },\n    {\n      \"id\": \"S3\",\n      \"summary\": \"Run lint and full test suite to verify clean codebase\",\n      \"acceptance\": [\n        \"npm run lint passes with zero warnings\",\n        \"npm run test passes with zero failures\",\n        \"grep confirms no remaining execute phase references in src/\"\n      ],\n      \"depends_on\": [\"S2\"]\n    }\n  ],\n  \"risks\": [\n    { \"risk\": \"Existing DB rows retain phase_execute column data\", \"mitigation\": \"SQLite ignores unmapped columns; no destructive migration needed\" },\n    { \"risk\": \"External API consumers depend on phaseExecute field\", \"mitigation\": \"Intended breaking change per issue requirements\" }\n  ],\n  \"assumptions\": [\n    \"PHASE_ORDER and phaseColumn/phaseKey maps already exclude execute — confirmed by codebase audit\",\n    \"TypeORM synchronize or schema drift handles the orphaned DB column safely\"\n  ],\n  \"open_questions\": []\n}\n","numLines":74,"startLine":1,"totalLines":74}}}
{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1776168000,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false},"uuid":"69ed62ee-7199-43f3-929b-b9a1e50bc898","session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_016vaRZwdkqnwWE686UtPq25","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now let me read all the files I need to modify. Let me start with the production code files.","signature":"EpkCClsIDBgCKkBguRp6MDRyV4sJihuNRKKsZzYyS0UbRi4fjQ2loI97X7qJDYQG+65CUCbKSZUVnkAq4oAfkh4bdrn7HEhjHXUSMhFjbGF1ZGUtc29ubmV0LTQtNjgAEgxro5N8bZYrnqJa7vIaDAMYXcoxOMUIUT+0NCIwN41EzukSN/w7z2gLnGqyFw063IXvxh2N4TvWESKIXavoSkjHeUg/sPNF2u0H0jNJKmxBIBxHar+3UjFYZhJzJqssHrjopcJwv6uJKV3h86Vn2Z2oO5Y/wfMkE1mfnSTsbUvCECHqF68bOjOSKhtQazQJUB2k5j2yv4v/tLii4VlQVN6btNAm1Og5nUAyNAoB2dS+UhpzBMhkQSwHQIMYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2891,"cache_read_input_tokens":22731,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2891},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"26611a3e-9bed-460e-8d4c-c42b015fd415"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_016vaRZwdkqnwWE686UtPq25","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me read all files I need to modify in parallel."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2891,"cache_read_input_tokens":22731,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2891},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"344380cf-c91f-4afb-b441-140036b128da"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_016vaRZwdkqnwWE686UtPq25","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016rgdR9sqMqeH9nsBr4cb9B","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/database/entities/task.entity.ts"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2891,"cache_read_input_tokens":22731,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2891},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"b54ab7f4-81e5-4e8e-97ea-c608c52927cc"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016rgdR9sqMqeH9nsBr4cb9B","type":"tool_result","content":"     1→import {\n     2→  Entity,\n     3→  PrimaryGeneratedColumn,\n     4→  Column,\n     5→  CreateDateColumn,\n     6→  UpdateDateColumn,\n     7→  Unique,\n     8→} from 'typeorm';\n     9→\n    10→@Entity('tasks')\n    11→@Unique(['owner', 'issue', 'repo'])\n    12→export class TaskEntity {\n    13→  @PrimaryGeneratedColumn()\n    14→  id!: number;\n    15→\n    16→  @Column({ default: 'AgentSDE' })\n    17→  owner!: string;\n    18→\n    19→  @Column()\n    20→  issue!: number;\n    21→\n    22→  @Column()\n    23→  repo!: string;\n    24→\n    25→  @Column({ default: 'auto' })\n    26→  directive!: string;\n    27→\n    28→  @Column({ name: 'current_phase', default: 'refine' })\n    29→  currentPhase!: string;\n    30→\n    31→  @Column({ default: 'active' })\n    32→  status!: string;\n    33→\n    34→  @Column({ name: 'gate_phase', type: 'varchar', nullable: true })\n    35→  gatePhase!: string | null;\n    36→\n    37→  @Column({ name: 'gate_reason', type: 'varchar', nullable: true })\n    38→  gateReason!: string | null;\n    39→\n    40→  @Column({ name: 'pr_number', type: 'integer', nullable: true })\n    41→  prNumber!: number | null;\n    42→\n    43→  @Column({ name: 'pr_branch', type: 'varchar', nullable: true })\n    44→  prBranch!: string | null;\n    45→\n    46→  @Column({ name: 'phase_refine', default: 'pending' })\n    47→  phaseRefine!: string;\n    48→\n    49→  @Column({ name: 'phase_plan', default: 'pending' })\n    50→  phasePlan!: string;\n    51→\n    52→  @Column({ name: 'phase_execute', default: 'pending' })\n    53→  phaseExecute!: string;\n    54→\n    55→  @Column({ name: 'phase_review', default: 'pending' })\n    56→  phaseReview!: string;\n    57→\n    58→  @Column({ name: 'phase_address_review', default: 'pending' })\n    59→  phaseAddressReview!: string;\n    60→\n    61→  @Column({ name: 'phase_deliver', default: 'pending' })\n    62→  phaseDeliver!: string;\n    63→\n    64→  @Column({ name: 'phase_compound', default: 'pending' })\n    65→  phaseCompound!: string;\n    66→\n    67→  @Column({ name: 'quality_gate_override', default: false })\n    68→  qualityGateOverride!: boolean;\n    69→\n    70→  @Column({ name: 'replan_attempt', type: 'integer', default: 0 })\n    71→  replanAttempt!: number;\n    72→\n    73→  @Column({ name: 'started_at', type: 'datetime', nullable: true })\n    74→  startedAt!: Date | null;\n    75→\n    76→  @Column({ name: 'completed_at', type: 'datetime', nullable: true })\n    77→  completedAt!: Date | null;\n    78→\n    79→  @CreateDateColumn({ name: 'created_at' })\n    80→  createdAt!: Date;\n    81→\n    82→  @UpdateDateColumn({ name: 'updated_at' })\n    83→  updatedAt!: Date;\n    84→}\n    85→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"c76b9f2d-cf07-4773-b8df-a1f3d528025a","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/database/entities/task.entity.ts","content":"import {\n  Entity,\n  PrimaryGeneratedColumn,\n  Column,\n  CreateDateColumn,\n  UpdateDateColumn,\n  Unique,\n} from 'typeorm';\n\n@Entity('tasks')\n@Unique(['owner', 'issue', 'repo'])\nexport class TaskEntity {\n  @PrimaryGeneratedColumn()\n  id!: number;\n\n  @Column({ default: 'AgentSDE' })\n  owner!: string;\n\n  @Column()\n  issue!: number;\n\n  @Column()\n  repo!: string;\n\n  @Column({ default: 'auto' })\n  directive!: string;\n\n  @Column({ name: 'current_phase', default: 'refine' })\n  currentPhase!: string;\n\n  @Column({ default: 'active' })\n  status!: string;\n\n  @Column({ name: 'gate_phase', type: 'varchar', nullable: true })\n  gatePhase!: string | null;\n\n  @Column({ name: 'gate_reason', type: 'varchar', nullable: true })\n  gateReason!: string | null;\n\n  @Column({ name: 'pr_number', type: 'integer', nullable: true })\n  prNumber!: number | null;\n\n  @Column({ name: 'pr_branch', type: 'varchar', nullable: true })\n  prBranch!: string | null;\n\n  @Column({ name: 'phase_refine', default: 'pending' })\n  phaseRefine!: string;\n\n  @Column({ name: 'phase_plan', default: 'pending' })\n  phasePlan!: string;\n\n  @Column({ name: 'phase_execute', default: 'pending' })\n  phaseExecute!: string;\n\n  @Column({ name: 'phase_review', default: 'pending' })\n  phaseReview!: string;\n\n  @Column({ name: 'phase_address_review', default: 'pending' })\n  phaseAddressReview!: string;\n\n  @Column({ name: 'phase_deliver', default: 'pending' })\n  phaseDeliver!: string;\n\n  @Column({ name: 'phase_compound', default: 'pending' })\n  phaseCompound!: string;\n\n  @Column({ name: 'quality_gate_override', default: false })\n  qualityGateOverride!: boolean;\n\n  @Column({ name: 'replan_attempt', type: 'integer', default: 0 })\n  replanAttempt!: number;\n\n  @Column({ name: 'started_at', type: 'datetime', nullable: true })\n  startedAt!: Date | null;\n\n  @Column({ name: 'completed_at', type: 'datetime', nullable: true })\n  completedAt!: Date | null;\n\n  @CreateDateColumn({ name: 'created_at' })\n  createdAt!: Date;\n\n  @UpdateDateColumn({ name: 'updated_at' })\n  updatedAt!: Date;\n}\n","numLines":85,"startLine":1,"totalLines":85}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_016vaRZwdkqnwWE686UtPq25","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018PxqvroaJs1Zoab61WaXdV","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/task-state/task-state.service.ts"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2891,"cache_read_input_tokens":22731,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2891},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"0b0506e6-40e1-44d6-a201-92a8f249d666"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018PxqvroaJs1Zoab61WaXdV","type":"tool_result","content":"     1→import { Injectable, Logger } from '@nestjs/common';\n     2→import { InjectRepository } from '@nestjs/typeorm';\n     3→import { In, Repository } from 'typeorm';\n     4→import { EventBusService } from '../event-bus';\n     5→import { TaskEntity } from '../database/entities';\n     6→import { TenantConfigService } from '../tenant/tenant-config.service';\n     7→\n     8→@Injectable()\n     9→export class TaskStateService {\n    10→  private readonly logger = new Logger(TaskStateService.name);\n    11→\n    12→  constructor(\n    13→    @InjectRepository(TaskEntity)\n    14→    private readonly taskRepo: Repository<TaskEntity>,\n    15→    private readonly eventBus: EventBusService,\n    16→    private readonly tenantConfig: TenantConfigService,\n    17→  ) {}\n    18→\n    19→  async findByIssueAndRepo(\n    20→    issue: number,\n    21→    repo: string,\n    22→  ): Promise<TaskEntity | null> {\n    23→    const owner = this.tenantConfig.getConfig().org;\n    24→    return this.taskRepo.findOne({ where: { owner, issue, repo } });\n    25→  }\n    26→\n    27→  async createTask(\n    28→    issue: number,\n    29→    repo: string,\n    30→    directive: string = 'auto',\n    31→  ): Promise<TaskEntity> {\n    32→    const owner = this.tenantConfig.getConfig().org;\n    33→    const task = this.taskRepo.create({\n    34→      owner,\n    35→      issue,\n    36→      repo,\n    37→      directive,\n    38→      currentPhase: 'refine',\n    39→      status: 'active',\n    40→      phaseRefine: 'pending',\n    41→      phasePlan: 'pending',\n    42→      phaseExecute: 'pending',\n    43→      phaseReview: 'pending',\n    44→      phaseAddressReview: 'pending',\n    45→      phaseDeliver: 'pending',\n    46→      phaseCompound: 'pending',\n    47→    });\n    48→    // New task with status 'active' — set startedAt\n    49→    if (task.startedAt === null || task.startedAt === undefined) {\n    50→      task.startedAt = new Date();\n    51→    }\n    52→    const saved = await this.taskRepo.save(task);\n    53→    try {\n    54→      this.eventBus.emit('task.updated', {\n    55→        taskId: saved.id,\n    56→        issue: saved.issue,\n    57→        repo: saved.repo,\n    58→        status: saved.status,\n    59→        currentPhase: saved.currentPhase,\n    60→        gatePhase: saved.gatePhase ?? null,\n    61→        gateReason: saved.gateReason ?? null,\n    62→      });\n    63→    } catch (err) {\n    64→      this.logger.error(\n    65→        `Failed to emit task.updated: ${(err as Error).message}`,\n    66→      );\n    67→    }\n    68→    return saved;\n    69→  }\n    70→\n    71→  async save(task: TaskEntity): Promise<TaskEntity> {\n    72→    const newStatus = task.status;\n    73→\n    74→    // Look up the previous status from DB (if task has an id)\n    75→    let previousStatus: string | undefined;\n    76→    if (task.id) {\n    77→      const existing = await this.taskRepo.findOne({ where: { id: task.id } });\n    78→      previousStatus = existing?.status;\n    79→    }\n    80→\n    81→    if (newStatus === 'active' && previousStatus !== 'active') {\n    82→      if (task.startedAt === null || task.startedAt === undefined) {\n    83→        task.startedAt = new Date();\n    84→      }\n    85→    }\n    86→\n    87→    if (newStatus === 'complete' || newStatus === 'failed') {\n    88→      if (previousStatus !== newStatus) {\n    89→        if (task.completedAt === null || task.completedAt === undefined) {\n    90→          task.completedAt = new Date();\n    91→        }\n    92→      }\n    93→    }\n    94→\n    95→    const saved = await this.taskRepo.save(task);\n    96→\n    97→    try {\n    98→      this.eventBus.emit('task.updated', {\n    99→        taskId: saved.id,\n   100→        issue: saved.issue,\n   101→        repo: saved.repo,\n   102→        status: saved.status,\n   103→        currentPhase: saved.currentPhase,\n   104→        gatePhase: saved.gatePhase ?? null,\n   105→        gateReason: saved.gateReason ?? null,\n   106→      });\n   107→    } catch (err) {\n   108→      this.logger.error(\n   109→        `Failed to emit task.updated: ${(err as Error).message}`,\n   110→      );\n   111→    }\n   112→\n   113→    return saved;\n   114→  }\n   115→\n   116→  async findByStatus(status: string): Promise<TaskEntity[]> {\n   117→    const owner = this.tenantConfig.getConfig().org;\n   118→    return this.taskRepo.find({ where: { owner, status } });\n   119→  }\n   120→\n   121→  async findByStatuses(statuses: string[]): Promise<TaskEntity[]> {\n   122→    const owner = this.tenantConfig.getConfig().org;\n   123→    return this.taskRepo.find({ where: { owner, status: In(statuses) } });\n   124→  }\n   125→\n   126→  async findAll(): Promise<TaskEntity[]> {\n   127→    const owner = this.tenantConfig.getConfig().org;\n   128→    return this.taskRepo.find({ where: { owner } });\n   129→  }\n   130→\n   131→  private phaseKey(phase: string): keyof TaskEntity {\n   132→    const map: Record<string, keyof TaskEntity> = {\n   133→      refine: 'phaseRefine',\n   134→      plan: 'phasePlan',\n   135→      review: 'phaseReview',\n   136→      address_review: 'phaseAddressReview',\n   137→      deliver: 'phaseDeliver',\n   138→      compound: 'phaseCompound',\n   139→    };\n   140→    const key = map[phase];\n   141→    if (!key) throw new Error(`Unknown phase: ${phase}`);\n   142→    return key;\n   143→  }\n   144→\n   145→  getPhaseStatus(task: TaskEntity, phase: string): string {\n   146→    return task[this.phaseKey(phase)] as string;\n   147→  }\n   148→\n   149→  /**\n   150→   * Update a single phase column and emit a task.updated event.\n   151→   */\n   152→  async updatePhaseStatus(\n   153→    taskId: number,\n   154→    phase: string,\n   155→    status: string,\n   156→  ): Promise<void> {\n   157→    await this.taskRepo.update(taskId, {\n   158→      [this.phaseKey(phase)]: status,\n   159→    });\n   160→    const task = await this.taskRepo.findOneByOrFail({ id: taskId });\n   161→    try {\n   162→      this.eventBus.emit('task.updated', {\n   163→        taskId: task.id,\n   164→        issue: task.issue,\n   165→        repo: task.repo,\n   166→        status: task.status,\n   167→        currentPhase: task.currentPhase,\n   168→        gatePhase: task.gatePhase ?? null,\n   169→        gateReason: task.gateReason ?? null,\n   170→      });\n   171→    } catch (err) {\n   172→      this.logger.error(\n   173→        `Failed to emit task.updated: ${(err as Error).message}`,\n   174→      );\n   175→    }\n   176→  }\n   177→\n   178→  /**\n   179→   * Update arbitrary task fields, handle timestamp logic, and emit event.\n   180→   */\n   181→  async updateTaskStatus(\n   182→    taskId: number,\n   183→    updates: Partial<TaskEntity>,\n   184→  ): Promise<void> {\n   185→    // If status is transitioning to a terminal state, set completedAt\n   186→    if (updates.status === 'complete' || updates.status === 'failed') {\n   187→      const existing = await this.taskRepo.findOneByOrFail({ id: taskId });\n   188→      if (\n   189→        existing.status !== updates.status &&\n   190→        (existing.completedAt === null || existing.completedAt === undefined)\n   191→      ) {\n   192→        updates = {\n   193→          ...updates,\n   194→          completedAt: new Date(),\n   195→        } as Partial<TaskEntity>;\n   196→      }\n   197→    }\n   198→    await this.taskRepo.update(taskId, updates);\n   199→    const task = await this.taskRepo.findOneByOrFail({ id: taskId });\n   200→    try {\n   201→      this.eventBus.emit('task.updated', {\n   202→        taskId: task.id,\n   203→        issue: task.issue,\n   204→        repo: task.repo,\n   205→        status: task.status,\n   206→        currentPhase: task.currentPhase,\n   207→        gatePhase: task.gatePhase ?? null,\n   208→        gateReason: task.gateReason ?? null,\n   209→      });\n   210→    } catch (err) {\n   211→      this.logger.error(\n   212→        `Failed to emit task.updated: ${(err as Error).message}`,\n   213→      );\n   214→    }\n   215→  }\n   216→\n   217→  /**\n   218→   * Mark a task as complete, set completedAt, and emit event.\n   219→   */\n   220→  async markComplete(taskId: number): Promise<void> {\n   221→    await this.updateTaskStatus(taskId, { status: 'complete' });\n   222→  }\n   223→\n   224→  async updatePrMetadata(\n   225→    taskId: number,\n   226→    prNumber: number,\n   227→    prBranch?: string | null,\n   228→  ): Promise<void> {\n   229→    await this.taskRepo.update(taskId, {\n   230→      prNumber,\n   231→      ...(prBranch != null ? { prBranch } : {}),\n   232→    });\n   233→  }\n   234→\n   235→  setPhaseStatus(task: TaskEntity, phase: string, status: string): void {\n   236→    (task as unknown as Record<string, unknown>)[this.phaseKey(phase)] = status;\n   237→  }\n   238→\n   239→  setGate(task: TaskEntity, phase: string, reason: string): void {\n   240→    task.gatePhase = phase;\n   241→    task.gateReason = reason;\n   242→    task.status = 'gated';\n   243→  }\n   244→\n   245→  clearGate(task: TaskEntity): void {\n   246→    task.gatePhase = null;\n   247→    task.gateReason = null;\n   248→  }\n   249→\n   250→  setQualityGateOverride(task: TaskEntity, value: boolean): void {\n   251→    task.qualityGateOverride = value;\n   252→  }\n   253→\n   254→  /**\n   255→   * Reset state for a retry: set the current phase status back to pending\n   256→   * and mark the task as active so it can be re-executed.\n   257→   */\n   258→  resetForRetry(task: TaskEntity): void {\n   259→    if (task.currentPhase) {\n   260→      this.setPhaseStatus(task, task.currentPhase, 'pending');\n   261→    }\n   262→    task.status = 'active';\n   263→  }\n   264→\n   265→  /**\n   266→   * Reset state for a full restart: clear all phase statuses, reset\n   267→   * currentPhase to refine, and mark the task as active.\n   268→   */\n   269→  resetForRestart(task: TaskEntity): void {\n   270→    this.setPhaseStatus(task, 'refine', 'pending');\n   271→    this.setPhaseStatus(task, 'plan', 'pending');\n   272→    this.setPhaseStatus(task, 'deliver', 'pending');\n   273→    this.setPhaseStatus(task, 'review', 'pending');\n   274→    this.setPhaseStatus(task, 'compound', 'pending');\n   275→    task.currentPhase = 'refine';\n   276→    task.status = 'active';\n   277→  }\n   278→\n   279→  /**\n   280→   * Reset state for a replan: resets all phases (via resetForRestart),\n   281→   * nullifies prNumber/prBranch, and increments replanAttempt.\n   282→   */\n   283→  resetForReplan(task: TaskEntity): void {\n   284→    this.resetForRestart(task);\n   285→    task.prNumber = null;\n   286→    task.prBranch = null;\n   287→    task.replanAttempt = (task.replanAttempt ?? 0) + 1;\n   288→  }\n   289→}\n   290→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"bddd6019-2dfa-4baa-b1c7-0414dbfca871","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/task-state/task-state.service.ts","content":"import { Injectable, Logger } from '@nestjs/common';\nimport { InjectRepository } from '@nestjs/typeorm';\nimport { In, Repository } from 'typeorm';\nimport { EventBusService } from '../event-bus';\nimport { TaskEntity } from '../database/entities';\nimport { TenantConfigService } from '../tenant/tenant-config.service';\n\n@Injectable()\nexport class TaskStateService {\n  private readonly logger = new Logger(TaskStateService.name);\n\n  constructor(\n    @InjectRepository(TaskEntity)\n    private readonly taskRepo: Repository<TaskEntity>,\n    private readonly eventBus: EventBusService,\n    private readonly tenantConfig: TenantConfigService,\n  ) {}\n\n  async findByIssueAndRepo(\n    issue: number,\n    repo: string,\n  ): Promise<TaskEntity | null> {\n    const owner = this.tenantConfig.getConfig().org;\n    return this.taskRepo.findOne({ where: { owner, issue, repo } });\n  }\n\n  async createTask(\n    issue: number,\n    repo: string,\n    directive: string = 'auto',\n  ): Promise<TaskEntity> {\n    const owner = this.tenantConfig.getConfig().org;\n    const task = this.taskRepo.create({\n      owner,\n      issue,\n      repo,\n      directive,\n      currentPhase: 'refine',\n      status: 'active',\n      phaseRefine: 'pending',\n      phasePlan: 'pending',\n      phaseExecute: 'pending',\n      phaseReview: 'pending',\n      phaseAddressReview: 'pending',\n      phaseDeliver: 'pending',\n      phaseCompound: 'pending',\n    });\n    // New task with status 'active' — set startedAt\n    if (task.startedAt === null || task.startedAt === undefined) {\n      task.startedAt = new Date();\n    }\n    const saved = await this.taskRepo.save(task);\n    try {\n      this.eventBus.emit('task.updated', {\n        taskId: saved.id,\n        issue: saved.issue,\n        repo: saved.repo,\n        status: saved.status,\n        currentPhase: saved.currentPhase,\n        gatePhase: saved.gatePhase ?? null,\n        gateReason: saved.gateReason ?? null,\n      });\n    } catch (err) {\n      this.logger.error(\n        `Failed to emit task.updated: ${(err as Error).message}`,\n      );\n    }\n    return saved;\n  }\n\n  async save(task: TaskEntity): Promise<TaskEntity> {\n    const newStatus = task.status;\n\n    // Look up the previous status from DB (if task has an id)\n    let previousStatus: string | undefined;\n    if (task.id) {\n      const existing = await this.taskRepo.findOne({ where: { id: task.id } });\n      previousStatus = existing?.status;\n    }\n\n    if (newStatus === 'active' && previousStatus !== 'active') {\n      if (task.startedAt === null || task.startedAt === undefined) {\n        task.startedAt = new Date();\n      }\n    }\n\n    if (newStatus === 'complete' || newStatus === 'failed') {\n      if (previousStatus !== newStatus) {\n        if (task.completedAt === null || task.completedAt === undefined) {\n          task.completedAt = new Date();\n        }\n      }\n    }\n\n    const saved = await this.taskRepo.save(task);\n\n    try {\n      this.eventBus.emit('task.updated', {\n        taskId: saved.id,\n        issue: saved.issue,\n        repo: saved.repo,\n        status: saved.status,\n        currentPhase: saved.currentPhase,\n        gatePhase: saved.gatePhase ?? null,\n        gateReason: saved.gateReason ?? null,\n      });\n    } catch (err) {\n      this.logger.error(\n        `Failed to emit task.updated: ${(err as Error).message}`,\n      );\n    }\n\n    return saved;\n  }\n\n  async findByStatus(status: string): Promise<TaskEntity[]> {\n    const owner = this.tenantConfig.getConfig().org;\n    return this.taskRepo.find({ where: { owner, status } });\n  }\n\n  async findByStatuses(statuses: string[]): Promise<TaskEntity[]> {\n    const owner = this.tenantConfig.getConfig().org;\n    return this.taskRepo.find({ where: { owner, status: In(statuses) } });\n  }\n\n  async findAll(): Promise<TaskEntity[]> {\n    const owner = this.tenantConfig.getConfig().org;\n    return this.taskRepo.find({ where: { owner } });\n  }\n\n  private phaseKey(phase: string): keyof TaskEntity {\n    const map: Record<string, keyof TaskEntity> = {\n      refine: 'phaseRefine',\n      plan: 'phasePlan',\n      review: 'phaseReview',\n      address_review: 'phaseAddressReview',\n      deliver: 'phaseDeliver',\n      compound: 'phaseCompound',\n    };\n    const key = map[phase];\n    if (!key) throw new Error(`Unknown phase: ${phase}`);\n    return key;\n  }\n\n  getPhaseStatus(task: TaskEntity, phase: string): string {\n    return task[this.phaseKey(phase)] as string;\n  }\n\n  /**\n   * Update a single phase column and emit a task.updated event.\n   */\n  async updatePhaseStatus(\n    taskId: number,\n    phase: string,\n    status: string,\n  ): Promise<void> {\n    await this.taskRepo.update(taskId, {\n      [this.phaseKey(phase)]: status,\n    });\n    const task = await this.taskRepo.findOneByOrFail({ id: taskId });\n    try {\n      this.eventBus.emit('task.updated', {\n        taskId: task.id,\n        issue: task.issue,\n        repo: task.repo,\n        status: task.status,\n        currentPhase: task.currentPhase,\n        gatePhase: task.gatePhase ?? null,\n        gateReason: task.gateReason ?? null,\n      });\n    } catch (err) {\n      this.logger.error(\n        `Failed to emit task.updated: ${(err as Error).message}`,\n      );\n    }\n  }\n\n  /**\n   * Update arbitrary task fields, handle timestamp logic, and emit event.\n   */\n  async updateTaskStatus(\n    taskId: number,\n    updates: Partial<TaskEntity>,\n  ): Promise<void> {\n    // If status is transitioning to a terminal state, set completedAt\n    if (updates.status === 'complete' || updates.status === 'failed') {\n      const existing = await this.taskRepo.findOneByOrFail({ id: taskId });\n      if (\n        existing.status !== updates.status &&\n        (existing.completedAt === null || existing.completedAt === undefined)\n      ) {\n        updates = {\n          ...updates,\n          completedAt: new Date(),\n        } as Partial<TaskEntity>;\n      }\n    }\n    await this.taskRepo.update(taskId, updates);\n    const task = await this.taskRepo.findOneByOrFail({ id: taskId });\n    try {\n      this.eventBus.emit('task.updated', {\n        taskId: task.id,\n        issue: task.issue,\n        repo: task.repo,\n        status: task.status,\n        currentPhase: task.currentPhase,\n        gatePhase: task.gatePhase ?? null,\n        gateReason: task.gateReason ?? null,\n      });\n    } catch (err) {\n      this.logger.error(\n        `Failed to emit task.updated: ${(err as Error).message}`,\n      );\n    }\n  }\n\n  /**\n   * Mark a task as complete, set completedAt, and emit event.\n   */\n  async markComplete(taskId: number): Promise<void> {\n    await this.updateTaskStatus(taskId, { status: 'complete' });\n  }\n\n  async updatePrMetadata(\n    taskId: number,\n    prNumber: number,\n    prBranch?: string | null,\n  ): Promise<void> {\n    await this.taskRepo.update(taskId, {\n      prNumber,\n      ...(prBranch != null ? { prBranch } : {}),\n    });\n  }\n\n  setPhaseStatus(task: TaskEntity, phase: string, status: string): void {\n    (task as unknown as Record<string, unknown>)[this.phaseKey(phase)] = status;\n  }\n\n  setGate(task: TaskEntity, phase: string, reason: string): void {\n    task.gatePhase = phase;\n    task.gateReason = reason;\n    task.status = 'gated';\n  }\n\n  clearGate(task: TaskEntity): void {\n    task.gatePhase = null;\n    task.gateReason = null;\n  }\n\n  setQualityGateOverride(task: TaskEntity, value: boolean): void {\n    task.qualityGateOverride = value;\n  }\n\n  /**\n   * Reset state for a retry: set the current phase status back to pending\n   * and mark the task as active so it can be re-executed.\n   */\n  resetForRetry(task: TaskEntity): void {\n    if (task.currentPhase) {\n      this.setPhaseStatus(task, task.currentPhase, 'pending');\n    }\n    task.status = 'active';\n  }\n\n  /**\n   * Reset state for a full restart: clear all phase statuses, reset\n   * currentPhase to refine, and mark the task as active.\n   */\n  resetForRestart(task: TaskEntity): void {\n    this.setPhaseStatus(task, 'refine', 'pending');\n    this.setPhaseStatus(task, 'plan', 'pending');\n    this.setPhaseStatus(task, 'deliver', 'pending');\n    this.setPhaseStatus(task, 'review', 'pending');\n    this.setPhaseStatus(task, 'compound', 'pending');\n    task.currentPhase = 'refine';\n    task.status = 'active';\n  }\n\n  /**\n   * Reset state for a replan: resets all phases (via resetForRestart),\n   * nullifies prNumber/prBranch, and increments replanAttempt.\n   */\n  resetForReplan(task: TaskEntity): void {\n    this.resetForRestart(task);\n    task.prNumber = null;\n    task.prBranch = null;\n    task.replanAttempt = (task.replanAttempt ?? 0) + 1;\n  }\n}\n","numLines":290,"startLine":1,"totalLines":290}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_016vaRZwdkqnwWE686UtPq25","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01P6SdgVqVtYMPDLfCbG76jw","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/control-api/dto/phase-override.dto.ts"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2891,"cache_read_input_tokens":22731,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2891},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"11bcd3a9-c776-4c89-aec1-95cd063c9256"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01P6SdgVqVtYMPDLfCbG76jw","type":"tool_result","content":"     1→import { IsIn, IsString } from 'class-validator';\n     2→\n     3→const PHASES = [\n     4→  'refine',\n     5→  'plan',\n     6→  'execute',\n     7→  'review',\n     8→  'deliver',\n     9→  'compound',\n    10→] as const;\n    11→const STATUSES = ['pending', 'active', 'complete', 'skipped'] as const;\n    12→\n    13→export class PhaseOverrideDto {\n    14→  @IsString()\n    15→  @IsIn(PHASES)\n    16→  phase!: string;\n    17→\n    18→  @IsString()\n    19→  @IsIn(STATUSES)\n    20→  status!: string;\n    21→}\n    22→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"8579aae1-56c9-40d3-8e39-9fd8dbe70bec","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/control-api/dto/phase-override.dto.ts","content":"import { IsIn, IsString } from 'class-validator';\n\nconst PHASES = [\n  'refine',\n  'plan',\n  'execute',\n  'review',\n  'deliver',\n  'compound',\n] as const;\nconst STATUSES = ['pending', 'active', 'complete', 'skipped'] as const;\n\nexport class PhaseOverrideDto {\n  @IsString()\n  @IsIn(PHASES)\n  phase!: string;\n\n  @IsString()\n  @IsIn(STATUSES)\n  status!: string;\n}\n","numLines":22,"startLine":1,"totalLines":22}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_016vaRZwdkqnwWE686UtPq25","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013LSxJWawmp1TMnq5MAJdtF","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/control-api/dto/status-override.dto.ts"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2891,"cache_read_input_tokens":22731,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2891},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"c8011c5d-01e3-4f22-8479-24d969718058"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013LSxJWawmp1TMnq5MAJdtF","type":"tool_result","content":"     1→import { IsIn, IsOptional, IsString } from 'class-validator';\n     2→\n     3→const STATUSES = [\n     4→  'active',\n     5→  'complete',\n     6→  'failed',\n     7→  'stuck',\n     8→  'paused',\n     9→  'stopped',\n    10→  'gated',\n    11→  'blocked',\n    12→] as const;\n    13→const PHASES = [\n    14→  'refine',\n    15→  'plan',\n    16→  'execute',\n    17→  'review',\n    18→  'deliver',\n    19→  'compound',\n    20→] as const;\n    21→\n    22→export class StatusOverrideDto {\n    23→  @IsString()\n    24→  @IsIn(STATUSES)\n    25→  status!: string;\n    26→\n    27→  @IsOptional()\n    28→  @IsString()\n    29→  @IsIn(PHASES)\n    30→  currentPhase?: string;\n    31→}\n    32→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"702ca29c-01fd-41ec-bc96-a0c85d409656","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/control-api/dto/status-override.dto.ts","content":"import { IsIn, IsOptional, IsString } from 'class-validator';\n\nconst STATUSES = [\n  'active',\n  'complete',\n  'failed',\n  'stuck',\n  'paused',\n  'stopped',\n  'gated',\n  'blocked',\n] as const;\nconst PHASES = [\n  'refine',\n  'plan',\n  'execute',\n  'review',\n  'deliver',\n  'compound',\n] as const;\n\nexport class StatusOverrideDto {\n  @IsString()\n  @IsIn(STATUSES)\n  status!: string;\n\n  @IsOptional()\n  @IsString()\n  @IsIn(PHASES)\n  currentPhase?: string;\n}\n","numLines":32,"startLine":1,"totalLines":32}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01S19bj1k9gGbJwxU2Ype9zk","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now let me read all the spec files I need to modify.","signature":"EvEBClsIDBgCKkBLUOKrNhoCR/rO52SmTM3rXUzrYaIuG9mZD9wsv2/5Khh96EEERJ74KhofVGclxlTIXyvQqJEZACubEl9Jwl0uMhFjbGF1ZGUtc29ubmV0LTQtNjgAEgwg5ntobKxcHKKH9HAaDK1iAzRbl0DJqQNVJyIwxW6PX3l+Jg+qNQBABbJ+Kh5o3Y6rboowh2hvINdY3BWWGYz7k9vfOpSeOBWuBRmbKkR7xoerRwVMqzbJev8Cc0KhcoQo5Du7aMNpNPNNMIbKMc4T58TZHihGwSjEhSyZmnms7Z7iUdz4fqbLG41pmJeKKfWA8hgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":5943,"cache_read_input_tokens":25622,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":5943},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"bd48b535-1ad4-4d2e-8e67-34327c0e3434"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01S19bj1k9gGbJwxU2Ype9zk","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me read all spec files in parallel."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":5943,"cache_read_input_tokens":25622,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":5943},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"5f90a29d-139e-4fee-9613-a039d7ce0195"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01S19bj1k9gGbJwxU2Ype9zk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013Z7LZ3yAvd7oLzbL1ms7wj","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/database/database.module.spec.ts"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":5943,"cache_read_input_tokens":25622,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":5943},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"fee8533d-21b8-434f-9ffb-b6cdd736e4d1"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013Z7LZ3yAvd7oLzbL1ms7wj","type":"tool_result","content":"     1→import { Test, TestingModule } from '@nestjs/testing';\n     2→import { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm';\n     3→import { Repository } from 'typeorm';\n     4→import { TaskEntity } from './entities/task.entity';\n     5→import { EventEntity } from './entities/event.entity';\n     6→import { WebhookDeliveryEntity } from './entities/webhook-delivery.entity';\n     7→import { RuntimeConfigEntity } from './entities/runtime-config.entity';\n     8→\n     9→describe('DatabaseModule', () => {\n    10→  let module: TestingModule;\n    11→  let taskRepo: Repository<TaskEntity>;\n    12→  let eventRepo: Repository<EventEntity>;\n    13→  let webhookDeliveryRepo: Repository<WebhookDeliveryEntity>;\n    14→  let runtimeConfigRepo: Repository<RuntimeConfigEntity>;\n    15→\n    16→  beforeAll(async () => {\n    17→    module = await Test.createTestingModule({\n    18→      imports: [\n    19→        TypeOrmModule.forRoot({\n    20→          type: 'better-sqlite3',\n    21→          database: ':memory:',\n    22→          entities: [\n    23→            TaskEntity,\n    24→            EventEntity,\n    25→            WebhookDeliveryEntity,\n    26→            RuntimeConfigEntity,\n    27→          ],\n    28→          synchronize: true,\n    29→        }),\n    30→        TypeOrmModule.forFeature([\n    31→          TaskEntity,\n    32→          EventEntity,\n    33→          WebhookDeliveryEntity,\n    34→          RuntimeConfigEntity,\n    35→        ]),\n    36→      ],\n    37→    }).compile();\n    38→\n    39→    taskRepo = module.get<Repository<TaskEntity>>(\n    40→      getRepositoryToken(TaskEntity),\n    41→    );\n    42→    eventRepo = module.get<Repository<EventEntity>>(\n    43→      getRepositoryToken(EventEntity),\n    44→    );\n    45→    webhookDeliveryRepo = module.get<Repository<WebhookDeliveryEntity>>(\n    46→      getRepositoryToken(WebhookDeliveryEntity),\n    47→    );\n    48→    runtimeConfigRepo = module.get<Repository<RuntimeConfigEntity>>(\n    49→      getRepositoryToken(RuntimeConfigEntity),\n    50→    );\n    51→  });\n    52→\n    53→  afterAll(async () => {\n    54→    await module.close();\n    55→  });\n    56→\n    57→  it('should create a TaskEntity with default values', async () => {\n    58→    const task = taskRepo.create({ issue: 1, repo: 'owner/repo' });\n    59→    const saved = await taskRepo.save(task);\n    60→\n    61→    expect(saved.id).toBeDefined();\n    62→    expect(saved.directive).toBe('auto');\n    63→    expect(saved.currentPhase).toBe('refine');\n    64→    expect(saved.status).toBe('active');\n    65→    expect(saved.phaseRefine).toBe('pending');\n    66→    expect(saved.phasePlan).toBe('pending');\n    67→    expect(saved.phaseExecute).toBe('pending');\n    68→    expect(saved.phaseReview).toBe('pending');\n    69→    expect(saved.phaseDeliver).toBe('pending');\n    70→    expect(saved.phaseCompound).toBe('pending');\n    71→    expect(saved.createdAt).toBeInstanceOf(Date);\n    72→    expect(saved.updatedAt).toBeInstanceOf(Date);\n    73→  });\n    74→\n    75→  it('should enforce UNIQUE constraint on (issue, repo)', async () => {\n    76→    const task1 = taskRepo.create({ issue: 100, repo: 'owner/unique-test' });\n    77→    await taskRepo.save(task1);\n    78→\n    79→    const task2 = taskRepo.create({ issue: 100, repo: 'owner/unique-test' });\n    80→    await expect(taskRepo.save(task2)).rejects.toThrow();\n    81→  });\n    82→\n    83→  it('should allow same issue number in different repos', async () => {\n    84→    const task1 = taskRepo.create({ issue: 200, repo: 'owner/repo-a' });\n    85→    const task2 = taskRepo.create({ issue: 200, repo: 'owner/repo-b' });\n    86→\n    87→    const saved1 = await taskRepo.save(task1);\n    88→    const saved2 = await taskRepo.save(task2);\n    89→\n    90→    expect(saved1.id).not.toBe(saved2.id);\n    91→  });\n    92→\n    93→  it('should create an EventEntity linked to a task', async () => {\n    94→    const task = taskRepo.create({ issue: 300, repo: 'owner/event-test' });\n    95→    const savedTask = await taskRepo.save(task);\n    96→\n    97→    const event = eventRepo.create({\n    98→      taskId: savedTask.id,\n    99→      type: 'phase.transition',\n   100→      source: 'webhook',\n   101→      data: JSON.stringify({ from: 'refine', to: 'plan' }),\n   102→    });\n   103→    const savedEvent = await eventRepo.save(event);\n   104→\n   105→    expect(savedEvent.id).toBeDefined();\n   106→    expect(savedEvent.taskId).toBe(savedTask.id);\n   107→    expect(savedEvent.type).toBe('phase.transition');\n   108→    expect(savedEvent.source).toBe('webhook');\n   109→    expect(savedEvent.createdAt).toBeInstanceOf(Date);\n   110→  });\n   111→\n   112→  it('should handle nullable fields correctly on TaskEntity', async () => {\n   113→    const task = taskRepo.create({ issue: 400, repo: 'owner/nullable-test' });\n   114→    const saved = await taskRepo.save(task);\n   115→\n   116→    expect(saved.gatePhase).toBeNull();\n   117→    expect(saved.gateReason).toBeNull();\n   118→    expect(saved.prNumber).toBeNull();\n   119→    expect(saved.prBranch).toBeNull();\n   120→    expect(saved.startedAt).toBeNull();\n   121→    expect(saved.completedAt).toBeNull();\n   122→  });\n   123→\n   124→  it('should persist startedAt and completedAt on TaskEntity', async () => {\n   125→    const now = new Date();\n   126→    const task = taskRepo.create({\n   127→      issue: 500,\n   128→      repo: 'owner/timestamps-test',\n   129→      startedAt: now,\n   130→      completedAt: now,\n   131→    });\n   132→    const saved = await taskRepo.save(task);\n   133→\n   134→    expect(saved.startedAt).toBeInstanceOf(Date);\n   135→    expect(saved.completedAt).toBeInstanceOf(Date);\n   136→  });\n   137→\n   138→  it('should create a WebhookDeliveryEntity with default status', async () => {\n   139→    const delivery = webhookDeliveryRepo.create({\n   140→      deliveryId: 'delivery-001',\n   141→      source: 'github',\n   142→      eventType: 'issues',\n   143→      payload: JSON.stringify({ action: 'opened' }),\n   144→    });\n   145→    const saved = await webhookDeliveryRepo.save(delivery);\n   146→\n   147→    expect(saved.id).toBeDefined();\n   148→    expect(saved.deliveryId).toBe('delivery-001');\n   149→    expect(saved.source).toBe('github');\n   150→    expect(saved.eventType).toBe('issues');\n   151→    expect(saved.status).toBe('received');\n   152→    expect(saved.headers).toBeNull();\n   153→    expect(saved.receivedAt).toBeInstanceOf(Date);\n   154→  });\n   155→\n   156→  it('should enforce UNIQUE constraint on deliveryId in WebhookDeliveryEntity', async () => {\n   157→    const delivery1 = webhookDeliveryRepo.create({\n   158→      deliveryId: 'delivery-dup',\n   159→      source: 'github',\n   160→      eventType: 'push',\n   161→      payload: '{}',\n   162→    });\n   163→    await webhookDeliveryRepo.save(delivery1);\n   164→\n   165→    const delivery2 = webhookDeliveryRepo.create({\n   166→      deliveryId: 'delivery-dup',\n   167→      source: 'github',\n   168→      eventType: 'push',\n   169→      payload: '{}',\n   170→    });\n   171→    await expect(webhookDeliveryRepo.save(delivery2)).rejects.toThrow();\n   172→  });\n   173→\n   174→  it('should create and update a RuntimeConfigEntity', async () => {\n   175→    const config = runtimeConfigRepo.create({\n   176→      key: 'WATCHDOG_ENABLED',\n   177→      value: 'true',\n   178→    });\n   179→    const saved = await runtimeConfigRepo.save(config);\n   180→\n   181→    expect(saved.key).toBe('WATCHDOG_ENABLED');\n   182→    expect(saved.value).toBe('true');\n   183→    expect(saved.updatedAt).toBeInstanceOf(Date);\n   184→  });\n   185→\n   186→  it('should use key as primary column in RuntimeConfigEntity', async () => {\n   187→    const config = runtimeConfigRepo.create({\n   188→      key: 'SOME_FEATURE',\n   189→      value: '1',\n   190→    });\n   191→    await runtimeConfigRepo.save(config);\n   192→\n   193→    const found = await runtimeConfigRepo.findOne({\n   194→      where: { key: 'SOME_FEATURE' },\n   195→    });\n   196→    expect(found).toBeDefined();\n   197→    expect(found!.key).toBe('SOME_FEATURE');\n   198→  });\n   199→});\n   200→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"228697f7-5d62-4c92-8fd9-acbf766f8c2f","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/database/database.module.spec.ts","content":"import { Test, TestingModule } from '@nestjs/testing';\nimport { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm';\nimport { Repository } from 'typeorm';\nimport { TaskEntity } from './entities/task.entity';\nimport { EventEntity } from './entities/event.entity';\nimport { WebhookDeliveryEntity } from './entities/webhook-delivery.entity';\nimport { RuntimeConfigEntity } from './entities/runtime-config.entity';\n\ndescribe('DatabaseModule', () => {\n  let module: TestingModule;\n  let taskRepo: Repository<TaskEntity>;\n  let eventRepo: Repository<EventEntity>;\n  let webhookDeliveryRepo: Repository<WebhookDeliveryEntity>;\n  let runtimeConfigRepo: Repository<RuntimeConfigEntity>;\n\n  beforeAll(async () => {\n    module = await Test.createTestingModule({\n      imports: [\n        TypeOrmModule.forRoot({\n          type: 'better-sqlite3',\n          database: ':memory:',\n          entities: [\n            TaskEntity,\n            EventEntity,\n            WebhookDeliveryEntity,\n            RuntimeConfigEntity,\n          ],\n          synchronize: true,\n        }),\n        TypeOrmModule.forFeature([\n          TaskEntity,\n          EventEntity,\n          WebhookDeliveryEntity,\n          RuntimeConfigEntity,\n        ]),\n      ],\n    }).compile();\n\n    taskRepo = module.get<Repository<TaskEntity>>(\n      getRepositoryToken(TaskEntity),\n    );\n    eventRepo = module.get<Repository<EventEntity>>(\n      getRepositoryToken(EventEntity),\n    );\n    webhookDeliveryRepo = module.get<Repository<WebhookDeliveryEntity>>(\n      getRepositoryToken(WebhookDeliveryEntity),\n    );\n    runtimeConfigRepo = module.get<Repository<RuntimeConfigEntity>>(\n      getRepositoryToken(RuntimeConfigEntity),\n    );\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  it('should create a TaskEntity with default values', async () => {\n    const task = taskRepo.create({ issue: 1, repo: 'owner/repo' });\n    const saved = await taskRepo.save(task);\n\n    expect(saved.id).toBeDefined();\n    expect(saved.directive).toBe('auto');\n    expect(saved.currentPhase).toBe('refine');\n    expect(saved.status).toBe('active');\n    expect(saved.phaseRefine).toBe('pending');\n    expect(saved.phasePlan).toBe('pending');\n    expect(saved.phaseExecute).toBe('pending');\n    expect(saved.phaseReview).toBe('pending');\n    expect(saved.phaseDeliver).toBe('pending');\n    expect(saved.phaseCompound).toBe('pending');\n    expect(saved.createdAt).toBeInstanceOf(Date);\n    expect(saved.updatedAt).toBeInstanceOf(Date);\n  });\n\n  it('should enforce UNIQUE constraint on (issue, repo)', async () => {\n    const task1 = taskRepo.create({ issue: 100, repo: 'owner/unique-test' });\n    await taskRepo.save(task1);\n\n    const task2 = taskRepo.create({ issue: 100, repo: 'owner/unique-test' });\n    await expect(taskRepo.save(task2)).rejects.toThrow();\n  });\n\n  it('should allow same issue number in different repos', async () => {\n    const task1 = taskRepo.create({ issue: 200, repo: 'owner/repo-a' });\n    const task2 = taskRepo.create({ issue: 200, repo: 'owner/repo-b' });\n\n    const saved1 = await taskRepo.save(task1);\n    const saved2 = await taskRepo.save(task2);\n\n    expect(saved1.id).not.toBe(saved2.id);\n  });\n\n  it('should create an EventEntity linked to a task', async () => {\n    const task = taskRepo.create({ issue: 300, repo: 'owner/event-test' });\n    const savedTask = await taskRepo.save(task);\n\n    const event = eventRepo.create({\n      taskId: savedTask.id,\n      type: 'phase.transition',\n      source: 'webhook',\n      data: JSON.stringify({ from: 'refine', to: 'plan' }),\n    });\n    const savedEvent = await eventRepo.save(event);\n\n    expect(savedEvent.id).toBeDefined();\n    expect(savedEvent.taskId).toBe(savedTask.id);\n    expect(savedEvent.type).toBe('phase.transition');\n    expect(savedEvent.source).toBe('webhook');\n    expect(savedEvent.createdAt).toBeInstanceOf(Date);\n  });\n\n  it('should handle nullable fields correctly on TaskEntity', async () => {\n    const task = taskRepo.create({ issue: 400, repo: 'owner/nullable-test' });\n    const saved = await taskRepo.save(task);\n\n    expect(saved.gatePhase).toBeNull();\n    expect(saved.gateReason).toBeNull();\n    expect(saved.prNumber).toBeNull();\n    expect(saved.prBranch).toBeNull();\n    expect(saved.startedAt).toBeNull();\n    expect(saved.completedAt).toBeNull();\n  });\n\n  it('should persist startedAt and completedAt on TaskEntity', async () => {\n    const now = new Date();\n    const task = taskRepo.create({\n      issue: 500,\n      repo: 'owner/timestamps-test',\n      startedAt: now,\n      completedAt: now,\n    });\n    const saved = await taskRepo.save(task);\n\n    expect(saved.startedAt).toBeInstanceOf(Date);\n    expect(saved.completedAt).toBeInstanceOf(Date);\n  });\n\n  it('should create a WebhookDeliveryEntity with default status', async () => {\n    const delivery = webhookDeliveryRepo.create({\n      deliveryId: 'delivery-001',\n      source: 'github',\n      eventType: 'issues',\n      payload: JSON.stringify({ action: 'opened' }),\n    });\n    const saved = await webhookDeliveryRepo.save(delivery);\n\n    expect(saved.id).toBeDefined();\n    expect(saved.deliveryId).toBe('delivery-001');\n    expect(saved.source).toBe('github');\n    expect(saved.eventType).toBe('issues');\n    expect(saved.status).toBe('received');\n    expect(saved.headers).toBeNull();\n    expect(saved.receivedAt).toBeInstanceOf(Date);\n  });\n\n  it('should enforce UNIQUE constraint on deliveryId in WebhookDeliveryEntity', async () => {\n    const delivery1 = webhookDeliveryRepo.create({\n      deliveryId: 'delivery-dup',\n      source: 'github',\n      eventType: 'push',\n      payload: '{}',\n    });\n    await webhookDeliveryRepo.save(delivery1);\n\n    const delivery2 = webhookDeliveryRepo.create({\n      deliveryId: 'delivery-dup',\n      source: 'github',\n      eventType: 'push',\n      payload: '{}',\n    });\n    await expect(webhookDeliveryRepo.save(delivery2)).rejects.toThrow();\n  });\n\n  it('should create and update a RuntimeConfigEntity', async () => {\n    const config = runtimeConfigRepo.create({\n      key: 'WATCHDOG_ENABLED',\n      value: 'true',\n    });\n    const saved = await runtimeConfigRepo.save(config);\n\n    expect(saved.key).toBe('WATCHDOG_ENABLED');\n    expect(saved.value).toBe('true');\n    expect(saved.updatedAt).toBeInstanceOf(Date);\n  });\n\n  it('should use key as primary column in RuntimeConfigEntity', async () => {\n    const config = runtimeConfigRepo.create({\n      key: 'SOME_FEATURE',\n      value: '1',\n    });\n    await runtimeConfigRepo.save(config);\n\n    const found = await runtimeConfigRepo.findOne({\n      where: { key: 'SOME_FEATURE' },\n    });\n    expect(found).toBeDefined();\n    expect(found!.key).toBe('SOME_FEATURE');\n  });\n});\n","numLines":200,"startLine":1,"totalLines":200}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01S19bj1k9gGbJwxU2Ype9zk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Xgx6JNyMdGUDpaNyetrpGc","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/task-state/task-state.service.spec.ts"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":5943,"cache_read_input_tokens":25622,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":5943},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"7509cb5d-6ba3-4f2e-b93b-d520e2a317a7"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Xgx6JNyMdGUDpaNyetrpGc","type":"tool_result","content":"     1→import { Test, TestingModule } from '@nestjs/testing';\n     2→import { TypeOrmModule } from '@nestjs/typeorm';\n     3→import { TaskEntity } from '../database/entities';\n     4→import { TaskStateService } from './task-state.service';\n     5→import { EventBusService } from '../event-bus';\n     6→import { TenantConfigService } from '../tenant/tenant-config.service';\n     7→\n     8→const mockTenantConfig = {\n     9→  getConfig: () => ({ org: 'AgentSDE' }),\n    10→};\n    11→\n    12→const mockEventBus = {\n    13→  emit: jest.fn(),\n    14→  on: jest.fn(),\n    15→};\n    16→\n    17→describe('TaskStateService', () => {\n    18→  let service: TaskStateService;\n    19→  let module: TestingModule;\n    20→\n    21→  beforeEach(async () => {\n    22→    jest.clearAllMocks();\n    23→\n    24→    module = await Test.createTestingModule({\n    25→      imports: [\n    26→        TypeOrmModule.forRoot({\n    27→          type: 'better-sqlite3',\n    28→          database: ':memory:',\n    29→          entities: [TaskEntity],\n    30→          synchronize: true,\n    31→        }),\n    32→        TypeOrmModule.forFeature([TaskEntity]),\n    33→      ],\n    34→      providers: [\n    35→        TaskStateService,\n    36→        { provide: EventBusService, useValue: mockEventBus },\n    37→        { provide: TenantConfigService, useValue: mockTenantConfig },\n    38→      ],\n    39→    }).compile();\n    40→\n    41→    service = module.get<TaskStateService>(TaskStateService);\n    42→  });\n    43→\n    44→  afterEach(async () => {\n    45→    await module.close();\n    46→  });\n    47→\n    48→  describe('createTask', () => {\n    49→    it('creates task with correct defaults (all phases pending, status active)', async () => {\n    50→      const task = await service.createTask(42, 'owner/repo');\n    51→\n    52→      expect(task.id).toBeDefined();\n    53→      expect(task.owner).toBe('AgentSDE');\n    54→      expect(task.issue).toBe(42);\n    55→      expect(task.repo).toBe('owner/repo');\n    56→      expect(task.directive).toBe('auto');\n    57→      expect(task.currentPhase).toBe('refine');\n    58→      expect(task.status).toBe('active');\n    59→      expect(task.phaseRefine).toBe('pending');\n    60→      expect(task.phasePlan).toBe('pending');\n    61→      expect(task.phaseExecute).toBe('pending');\n    62→      expect(task.phaseReview).toBe('pending');\n    63→      expect(task.phaseDeliver).toBe('pending');\n    64→      expect(task.phaseCompound).toBe('pending');\n    65→      expect(task.gatePhase).toBeNull();\n    66→      expect(task.gateReason).toBeNull();\n    67→    });\n    68→\n    69→    it('accepts a custom directive', async () => {\n    70→      const task = await service.createTask(42, 'owner/repo', 'careful');\n    71→      expect(task.directive).toBe('careful');\n    72→    });\n    73→  });\n    74→\n    75→  describe('findByIssueAndRepo', () => {\n    76→    it('returns task when it exists', async () => {\n    77→      await service.createTask(42, 'owner/repo');\n    78→      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n    79→      expect(found).not.toBeNull();\n    80→      expect(found!.issue).toBe(42);\n    81→      expect(found!.repo).toBe('owner/repo');\n    82→    });\n    83→\n    84→    it('returns null for non-existent task', async () => {\n    85→      const found = await service.findByIssueAndRepo(999, 'no/repo');\n    86→      expect(found).toBeNull();\n    87→    });\n    88→  });\n    89→\n    90→  describe('save', () => {\n    91→    it('updates an existing task', async () => {\n    92→      const task = await service.createTask(42, 'owner/repo');\n    93→      task.status = 'complete';\n    94→      task.currentPhase = 'deliver';\n    95→      const saved = await service.save(task);\n    96→      expect(saved.status).toBe('complete');\n    97→      expect(saved.currentPhase).toBe('deliver');\n    98→      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n    99→      expect(found!.status).toBe('complete');\n   100→      expect(found!.currentPhase).toBe('deliver');\n   101→    });\n   102→  });\n   103→\n   104→  describe('findByStatus', () => {\n   105→    it('returns tasks matching the status', async () => {\n   106→      await service.createTask(1, 'owner/repo-a');\n   107→      const task2 = await service.createTask(2, 'owner/repo-b');\n   108→      task2.status = 'complete';\n   109→      await service.save(task2);\n   110→      const active = await service.findByStatus('active');\n   111→      const complete = await service.findByStatus('complete');\n   112→      expect(active).toHaveLength(1);\n   113→      expect(active[0].issue).toBe(1);\n   114→      expect(complete).toHaveLength(1);\n   115→      expect(complete[0].issue).toBe(2);\n   116→    });\n   117→  });\n   118→\n   119→  describe('findAll', () => {\n   120→    it('returns all tasks', async () => {\n   121→      await service.createTask(1, 'owner/repo-a');\n   122→      await service.createTask(2, 'owner/repo-b');\n   123→      const all = await service.findAll();\n   124→      expect(all).toHaveLength(2);\n   125→    });\n   126→  });\n   127→\n   128→  describe('getPhaseStatus / setPhaseStatus', () => {\n   129→    const phases = ['refine', 'plan', 'deliver', 'review', 'compound'];\n   130→\n   131→    it.each(phases)('gets and sets phase status for %s', async (phase) => {\n   132→      const task = await service.createTask(42, 'owner/repo');\n   133→      expect(service.getPhaseStatus(task, phase)).toBe('pending');\n   134→      service.setPhaseStatus(task, phase, 'in_progress');\n   135→      expect(service.getPhaseStatus(task, phase)).toBe('in_progress');\n   136→      const saved = await service.save(task);\n   137→      expect(service.getPhaseStatus(saved, phase)).toBe('in_progress');\n   138→    });\n   139→  });\n   140→\n   141→  describe('setGate', () => {\n   142→    it('sets gate fields and status to gated', async () => {\n   143→      const task = await service.createTask(42, 'owner/repo');\n   144→      service.setGate(task, 'review', 'Needs human approval');\n   145→      expect(task.gatePhase).toBe('review');\n   146→      expect(task.gateReason).toBe('Needs human approval');\n   147→      expect(task.status).toBe('gated');\n   148→      const saved = await service.save(task);\n   149→      expect(saved.gatePhase).toBe('review');\n   150→      expect(saved.gateReason).toBe('Needs human approval');\n   151→      expect(saved.status).toBe('gated');\n   152→    });\n   153→  });\n   154→\n   155→  describe('clearGate', () => {\n   156→    it('clears gate fields', async () => {\n   157→      const task = await service.createTask(42, 'owner/repo');\n   158→      service.setGate(task, 'review', 'Needs approval');\n   159→      await service.save(task);\n   160→      service.clearGate(task);\n   161→      expect(task.gatePhase).toBeNull();\n   162→      expect(task.gateReason).toBeNull();\n   163→      const saved = await service.save(task);\n   164→      expect(saved.gatePhase).toBeNull();\n   165→      expect(saved.gateReason).toBeNull();\n   166→    });\n   167→  });\n   168→\n   169→  describe('unique constraint', () => {\n   170→    it('throws on duplicate (issue, repo)', async () => {\n   171→      await service.createTask(42, 'owner/repo');\n   172→      await expect(service.createTask(42, 'owner/repo')).rejects.toThrow();\n   173→    });\n   174→  });\n   175→\n   176→  describe('task.updated event emission', () => {\n   177→    it('emits task.updated after createTask', async () => {\n   178→      const task = await service.createTask(42, 'owner/repo');\n   179→\n   180→      expect(mockEventBus.emit).toHaveBeenCalledWith(\n   181→        'task.updated',\n   182→        expect.objectContaining({\n   183→          taskId: task.id,\n   184→          issue: 42,\n   185→          repo: 'owner/repo',\n   186→          status: 'active',\n   187→          currentPhase: 'refine',\n   188→        }),\n   189→      );\n   190→    });\n   191→\n   192→    it('emits task.updated after save', async () => {\n   193→      const task = await service.createTask(42, 'owner/repo');\n   194→      jest.clearAllMocks();\n   195→\n   196→      task.currentPhase = 'plan';\n   197→      const saved = await service.save(task);\n   198→\n   199→      expect(mockEventBus.emit).toHaveBeenCalledWith(\n   200→        'task.updated',\n   201→        expect.objectContaining({\n   202→          taskId: saved.id,\n   203→          issue: 42,\n   204→          repo: 'owner/repo',\n   205→          status: 'active',\n   206→          currentPhase: 'plan',\n   207→        }),\n   208→      );\n   209→    });\n   210→  });\n   211→\n   212→  describe('startedAt timestamp', () => {\n   213→    it('sets startedAt when creating task with active status', async () => {\n   214→      const task = await service.createTask(42, 'owner/repo');\n   215→      expect(task.startedAt).toBeInstanceOf(Date);\n   216→    });\n   217→\n   218→    it('sets startedAt when transitioning from non-active to active', async () => {\n   219→      const task = await service.createTask(42, 'owner/repo');\n   220→      // Clear startedAt and set to gated\n   221→      task.startedAt = null;\n   222→      task.status = 'gated';\n   223→      await service.save(task);\n   224→\n   225→      // Now transition back to active\n   226→      task.status = 'active';\n   227→      const saved = await service.save(task);\n   228→      expect(saved.startedAt).toBeInstanceOf(Date);\n   229→    });\n   230→\n   231→    it('does not overwrite startedAt if already set', async () => {\n   232→      const task = await service.createTask(42, 'owner/repo');\n   233→      const originalStartedAt = task.startedAt;\n   234→\n   235→      // Set to gated then back to active\n   236→      task.status = 'gated';\n   237→      await service.save(task);\n   238→      task.status = 'active';\n   239→      const saved = await service.save(task);\n   240→\n   241→      expect(saved.startedAt).toEqual(originalStartedAt);\n   242→    });\n   243→  });\n   244→\n   245→  describe('resetForReplan', () => {\n   246→    it('resets all phases, clears PR state, and increments replanAttempt', async () => {\n   247→      const task = await service.createTask(42, 'owner/repo');\n   248→      task.prNumber = 99;\n   249→      task.prBranch = 'feat/99-branch';\n   250→      task.replanAttempt = 0;\n   251→      task.currentPhase = 'deliver';\n   252→      service.setPhaseStatus(task, 'refine', 'done');\n   253→      service.setPhaseStatus(task, 'plan', 'done');\n   254→      service.setPhaseStatus(task, 'deliver', 'active');\n   255→\n   256→      service.resetForReplan(task);\n   257→\n   258→      expect(task.prNumber).toBeNull();\n   259→      expect(task.prBranch).toBeNull();\n   260→      expect(task.replanAttempt).toBe(1);\n   261→      expect(task.currentPhase).toBe('refine');\n   262→      expect(task.status).toBe('active');\n   263→      expect(service.getPhaseStatus(task, 'refine')).toBe('pending');\n   264→      expect(service.getPhaseStatus(task, 'plan')).toBe('pending');\n   265→      expect(service.getPhaseStatus(task, 'deliver')).toBe('pending');\n   266→    });\n   267→\n   268→    it('increments replanAttempt on each call (idempotent re-entry)', async () => {\n   269→      const task = await service.createTask(42, 'owner/repo');\n   270→      task.replanAttempt = 0;\n   271→\n   272→      service.resetForReplan(task);\n   273→      expect(task.replanAttempt).toBe(1);\n   274→\n   275→      service.resetForReplan(task);\n   276→      expect(task.replanAttempt).toBe(2);\n   277→    });\n   278→\n   279→    it('persists updated replanAttempt after save', async () => {\n   280→      const task = await service.createTask(42, 'owner/repo');\n   281→      task.prNumber = 7;\n   282→      task.prBranch = 'feat/7-foo';\n   283→\n   284→      service.resetForReplan(task);\n   285→      const saved = await service.save(task);\n   286→\n   287→      expect(saved.replanAttempt).toBe(1);\n   288→      expect(saved.prNumber).toBeNull();\n   289→      expect(saved.prBranch).toBeNull();\n   290→\n   291→      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n   292→      expect(found!.replanAttempt).toBe(1);\n   293→    });\n   294→  });\n   295→\n   296→  describe('completedAt timestamp', () => {\n   297→    it('sets completedAt when transitioning to complete', async () => {\n   298→      const task = await service.createTask(42, 'owner/repo');\n   299→      task.status = 'complete';\n   300→      const saved = await service.save(task);\n   301→      expect(saved.completedAt).toBeInstanceOf(Date);\n   302→    });\n   303→\n   304→    it('sets completedAt when transitioning to failed', async () => {\n   305→      const task = await service.createTask(42, 'owner/repo');\n   306→      task.status = 'failed';\n   307→      const saved = await service.save(task);\n   308→      expect(saved.completedAt).toBeInstanceOf(Date);\n   309→    });\n   310→\n   311→    it('does not overwrite completedAt if already set', async () => {\n   312→      const task = await service.createTask(42, 'owner/repo');\n   313→      task.status = 'complete';\n   314→      const firstSave = await service.save(task);\n   315→      const originalCompletedAt = firstSave.completedAt;\n   316→\n   317→      // Save again with same status - completedAt should remain unchanged\n   318→      const secondSave = await service.save(firstSave);\n   319→      expect(secondSave.completedAt).toEqual(originalCompletedAt);\n   320→    });\n   321→  });\n   322→\n   323→  describe('updatePhaseStatus', () => {\n   324→    it('updates the phase column and emits task.updated', async () => {\n   325→      const task = await service.createTask(42, 'owner/repo');\n   326→      jest.clearAllMocks();\n   327→\n   328→      await service.updatePhaseStatus(task.id, 'refine', 'complete');\n   329→\n   330→      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n   331→      expect(found!.phaseRefine).toBe('complete');\n   332→      expect(mockEventBus.emit).toHaveBeenCalledWith(\n   333→        'task.updated',\n   334→        expect.objectContaining({ taskId: task.id }),\n   335→      );\n   336→    });\n   337→  });\n   338→\n   339→  describe('updateTaskStatus', () => {\n   340→    it('updates arbitrary fields and emits task.updated', async () => {\n   341→      const task = await service.createTask(42, 'owner/repo');\n   342→      jest.clearAllMocks();\n   343→\n   344→      await service.updateTaskStatus(task.id, {\n   345→        currentPhase: 'plan',\n   346→        status: 'gated',\n   347→        gatePhase: 'plan',\n   348→        gateReason: 'needs approval',\n   349→      });\n   350→\n   351→      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n   352→      expect(found!.currentPhase).toBe('plan');\n   353→      expect(found!.status).toBe('gated');\n   354→      expect(found!.gatePhase).toBe('plan');\n   355→      expect(mockEventBus.emit).toHaveBeenCalledWith(\n   356→        'task.updated',\n   357→        expect.objectContaining({ taskId: task.id, status: 'gated' }),\n   358→      );\n   359→    });\n   360→\n   361→    it('sets completedAt when transitioning to complete', async () => {\n   362→      const task = await service.createTask(42, 'owner/repo');\n   363→\n   364→      await service.updateTaskStatus(task.id, { status: 'complete' });\n   365→\n   366→      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n   367→      expect(found!.completedAt).toBeInstanceOf(Date);\n   368→    });\n   369→  });\n   370→\n   371→  describe('markComplete', () => {\n   372→    it('sets status to complete with completedAt and emits event', async () => {\n   373→      const task = await service.createTask(42, 'owner/repo');\n   374→      jest.clearAllMocks();\n   375→\n   376→      await service.markComplete(task.id);\n   377→\n   378→      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n   379→      expect(found!.status).toBe('complete');\n   380→      expect(found!.completedAt).toBeInstanceOf(Date);\n   381→      expect(mockEventBus.emit).toHaveBeenCalledWith(\n   382→        'task.updated',\n   383→        expect.objectContaining({ taskId: task.id, status: 'complete' }),\n   384→      );\n   385→    });\n   386→  });\n   387→});\n   388→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"472c1d38-8f02-4d8d-9dee-551fdb5279bc","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/task-state/task-state.service.spec.ts","content":"import { Test, TestingModule } from '@nestjs/testing';\nimport { TypeOrmModule } from '@nestjs/typeorm';\nimport { TaskEntity } from '../database/entities';\nimport { TaskStateService } from './task-state.service';\nimport { EventBusService } from '../event-bus';\nimport { TenantConfigService } from '../tenant/tenant-config.service';\n\nconst mockTenantConfig = {\n  getConfig: () => ({ org: 'AgentSDE' }),\n};\n\nconst mockEventBus = {\n  emit: jest.fn(),\n  on: jest.fn(),\n};\n\ndescribe('TaskStateService', () => {\n  let service: TaskStateService;\n  let module: TestingModule;\n\n  beforeEach(async () => {\n    jest.clearAllMocks();\n\n    module = await Test.createTestingModule({\n      imports: [\n        TypeOrmModule.forRoot({\n          type: 'better-sqlite3',\n          database: ':memory:',\n          entities: [TaskEntity],\n          synchronize: true,\n        }),\n        TypeOrmModule.forFeature([TaskEntity]),\n      ],\n      providers: [\n        TaskStateService,\n        { provide: EventBusService, useValue: mockEventBus },\n        { provide: TenantConfigService, useValue: mockTenantConfig },\n      ],\n    }).compile();\n\n    service = module.get<TaskStateService>(TaskStateService);\n  });\n\n  afterEach(async () => {\n    await module.close();\n  });\n\n  describe('createTask', () => {\n    it('creates task with correct defaults (all phases pending, status active)', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n\n      expect(task.id).toBeDefined();\n      expect(task.owner).toBe('AgentSDE');\n      expect(task.issue).toBe(42);\n      expect(task.repo).toBe('owner/repo');\n      expect(task.directive).toBe('auto');\n      expect(task.currentPhase).toBe('refine');\n      expect(task.status).toBe('active');\n      expect(task.phaseRefine).toBe('pending');\n      expect(task.phasePlan).toBe('pending');\n      expect(task.phaseExecute).toBe('pending');\n      expect(task.phaseReview).toBe('pending');\n      expect(task.phaseDeliver).toBe('pending');\n      expect(task.phaseCompound).toBe('pending');\n      expect(task.gatePhase).toBeNull();\n      expect(task.gateReason).toBeNull();\n    });\n\n    it('accepts a custom directive', async () => {\n      const task = await service.createTask(42, 'owner/repo', 'careful');\n      expect(task.directive).toBe('careful');\n    });\n  });\n\n  describe('findByIssueAndRepo', () => {\n    it('returns task when it exists', async () => {\n      await service.createTask(42, 'owner/repo');\n      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n      expect(found).not.toBeNull();\n      expect(found!.issue).toBe(42);\n      expect(found!.repo).toBe('owner/repo');\n    });\n\n    it('returns null for non-existent task', async () => {\n      const found = await service.findByIssueAndRepo(999, 'no/repo');\n      expect(found).toBeNull();\n    });\n  });\n\n  describe('save', () => {\n    it('updates an existing task', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      task.status = 'complete';\n      task.currentPhase = 'deliver';\n      const saved = await service.save(task);\n      expect(saved.status).toBe('complete');\n      expect(saved.currentPhase).toBe('deliver');\n      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n      expect(found!.status).toBe('complete');\n      expect(found!.currentPhase).toBe('deliver');\n    });\n  });\n\n  describe('findByStatus', () => {\n    it('returns tasks matching the status', async () => {\n      await service.createTask(1, 'owner/repo-a');\n      const task2 = await service.createTask(2, 'owner/repo-b');\n      task2.status = 'complete';\n      await service.save(task2);\n      const active = await service.findByStatus('active');\n      const complete = await service.findByStatus('complete');\n      expect(active).toHaveLength(1);\n      expect(active[0].issue).toBe(1);\n      expect(complete).toHaveLength(1);\n      expect(complete[0].issue).toBe(2);\n    });\n  });\n\n  describe('findAll', () => {\n    it('returns all tasks', async () => {\n      await service.createTask(1, 'owner/repo-a');\n      await service.createTask(2, 'owner/repo-b');\n      const all = await service.findAll();\n      expect(all).toHaveLength(2);\n    });\n  });\n\n  describe('getPhaseStatus / setPhaseStatus', () => {\n    const phases = ['refine', 'plan', 'deliver', 'review', 'compound'];\n\n    it.each(phases)('gets and sets phase status for %s', async (phase) => {\n      const task = await service.createTask(42, 'owner/repo');\n      expect(service.getPhaseStatus(task, phase)).toBe('pending');\n      service.setPhaseStatus(task, phase, 'in_progress');\n      expect(service.getPhaseStatus(task, phase)).toBe('in_progress');\n      const saved = await service.save(task);\n      expect(service.getPhaseStatus(saved, phase)).toBe('in_progress');\n    });\n  });\n\n  describe('setGate', () => {\n    it('sets gate fields and status to gated', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      service.setGate(task, 'review', 'Needs human approval');\n      expect(task.gatePhase).toBe('review');\n      expect(task.gateReason).toBe('Needs human approval');\n      expect(task.status).toBe('gated');\n      const saved = await service.save(task);\n      expect(saved.gatePhase).toBe('review');\n      expect(saved.gateReason).toBe('Needs human approval');\n      expect(saved.status).toBe('gated');\n    });\n  });\n\n  describe('clearGate', () => {\n    it('clears gate fields', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      service.setGate(task, 'review', 'Needs approval');\n      await service.save(task);\n      service.clearGate(task);\n      expect(task.gatePhase).toBeNull();\n      expect(task.gateReason).toBeNull();\n      const saved = await service.save(task);\n      expect(saved.gatePhase).toBeNull();\n      expect(saved.gateReason).toBeNull();\n    });\n  });\n\n  describe('unique constraint', () => {\n    it('throws on duplicate (issue, repo)', async () => {\n      await service.createTask(42, 'owner/repo');\n      await expect(service.createTask(42, 'owner/repo')).rejects.toThrow();\n    });\n  });\n\n  describe('task.updated event emission', () => {\n    it('emits task.updated after createTask', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n\n      expect(mockEventBus.emit).toHaveBeenCalledWith(\n        'task.updated',\n        expect.objectContaining({\n          taskId: task.id,\n          issue: 42,\n          repo: 'owner/repo',\n          status: 'active',\n          currentPhase: 'refine',\n        }),\n      );\n    });\n\n    it('emits task.updated after save', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      jest.clearAllMocks();\n\n      task.currentPhase = 'plan';\n      const saved = await service.save(task);\n\n      expect(mockEventBus.emit).toHaveBeenCalledWith(\n        'task.updated',\n        expect.objectContaining({\n          taskId: saved.id,\n          issue: 42,\n          repo: 'owner/repo',\n          status: 'active',\n          currentPhase: 'plan',\n        }),\n      );\n    });\n  });\n\n  describe('startedAt timestamp', () => {\n    it('sets startedAt when creating task with active status', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      expect(task.startedAt).toBeInstanceOf(Date);\n    });\n\n    it('sets startedAt when transitioning from non-active to active', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      // Clear startedAt and set to gated\n      task.startedAt = null;\n      task.status = 'gated';\n      await service.save(task);\n\n      // Now transition back to active\n      task.status = 'active';\n      const saved = await service.save(task);\n      expect(saved.startedAt).toBeInstanceOf(Date);\n    });\n\n    it('does not overwrite startedAt if already set', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      const originalStartedAt = task.startedAt;\n\n      // Set to gated then back to active\n      task.status = 'gated';\n      await service.save(task);\n      task.status = 'active';\n      const saved = await service.save(task);\n\n      expect(saved.startedAt).toEqual(originalStartedAt);\n    });\n  });\n\n  describe('resetForReplan', () => {\n    it('resets all phases, clears PR state, and increments replanAttempt', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      task.prNumber = 99;\n      task.prBranch = 'feat/99-branch';\n      task.replanAttempt = 0;\n      task.currentPhase = 'deliver';\n      service.setPhaseStatus(task, 'refine', 'done');\n      service.setPhaseStatus(task, 'plan', 'done');\n      service.setPhaseStatus(task, 'deliver', 'active');\n\n      service.resetForReplan(task);\n\n      expect(task.prNumber).toBeNull();\n      expect(task.prBranch).toBeNull();\n      expect(task.replanAttempt).toBe(1);\n      expect(task.currentPhase).toBe('refine');\n      expect(task.status).toBe('active');\n      expect(service.getPhaseStatus(task, 'refine')).toBe('pending');\n      expect(service.getPhaseStatus(task, 'plan')).toBe('pending');\n      expect(service.getPhaseStatus(task, 'deliver')).toBe('pending');\n    });\n\n    it('increments replanAttempt on each call (idempotent re-entry)', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      task.replanAttempt = 0;\n\n      service.resetForReplan(task);\n      expect(task.replanAttempt).toBe(1);\n\n      service.resetForReplan(task);\n      expect(task.replanAttempt).toBe(2);\n    });\n\n    it('persists updated replanAttempt after save', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      task.prNumber = 7;\n      task.prBranch = 'feat/7-foo';\n\n      service.resetForReplan(task);\n      const saved = await service.save(task);\n\n      expect(saved.replanAttempt).toBe(1);\n      expect(saved.prNumber).toBeNull();\n      expect(saved.prBranch).toBeNull();\n\n      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n      expect(found!.replanAttempt).toBe(1);\n    });\n  });\n\n  describe('completedAt timestamp', () => {\n    it('sets completedAt when transitioning to complete', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      task.status = 'complete';\n      const saved = await service.save(task);\n      expect(saved.completedAt).toBeInstanceOf(Date);\n    });\n\n    it('sets completedAt when transitioning to failed', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      task.status = 'failed';\n      const saved = await service.save(task);\n      expect(saved.completedAt).toBeInstanceOf(Date);\n    });\n\n    it('does not overwrite completedAt if already set', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      task.status = 'complete';\n      const firstSave = await service.save(task);\n      const originalCompletedAt = firstSave.completedAt;\n\n      // Save again with same status - completedAt should remain unchanged\n      const secondSave = await service.save(firstSave);\n      expect(secondSave.completedAt).toEqual(originalCompletedAt);\n    });\n  });\n\n  describe('updatePhaseStatus', () => {\n    it('updates the phase column and emits task.updated', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      jest.clearAllMocks();\n\n      await service.updatePhaseStatus(task.id, 'refine', 'complete');\n\n      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n      expect(found!.phaseRefine).toBe('complete');\n      expect(mockEventBus.emit).toHaveBeenCalledWith(\n        'task.updated',\n        expect.objectContaining({ taskId: task.id }),\n      );\n    });\n  });\n\n  describe('updateTaskStatus', () => {\n    it('updates arbitrary fields and emits task.updated', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      jest.clearAllMocks();\n\n      await service.updateTaskStatus(task.id, {\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'needs approval',\n      });\n\n      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n      expect(found!.currentPhase).toBe('plan');\n      expect(found!.status).toBe('gated');\n      expect(found!.gatePhase).toBe('plan');\n      expect(mockEventBus.emit).toHaveBeenCalledWith(\n        'task.updated',\n        expect.objectContaining({ taskId: task.id, status: 'gated' }),\n      );\n    });\n\n    it('sets completedAt when transitioning to complete', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n\n      await service.updateTaskStatus(task.id, { status: 'complete' });\n\n      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n      expect(found!.completedAt).toBeInstanceOf(Date);\n    });\n  });\n\n  describe('markComplete', () => {\n    it('sets status to complete with completedAt and emits event', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      jest.clearAllMocks();\n\n      await service.markComplete(task.id);\n\n      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n      expect(found!.status).toBe('complete');\n      expect(found!.completedAt).toBeInstanceOf(Date);\n      expect(mockEventBus.emit).toHaveBeenCalledWith(\n        'task.updated',\n        expect.objectContaining({ taskId: task.id, status: 'complete' }),\n      );\n    });\n  });\n});\n","numLines":388,"startLine":1,"totalLines":388}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01S19bj1k9gGbJwxU2Ype9zk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01P2cZM7RgHucprMdpg8ANqX","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":5943,"cache_read_input_tokens":25622,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":5943},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"c0c7c566-f414-4173-8974-4389d79b2008"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01S19bj1k9gGbJwxU2Ype9zk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Y7y1tyQp9eh3T1Ci5HkH4F","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/directive/directive.service.spec.ts"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":5943,"cache_read_input_tokens":25622,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":5943},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"e63ce4b1-5a74-4be3-ab8a-7a798c9e860e"}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"File content (16655 tokens) exceeds maximum allowed tokens (10000). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.","is_error":true,"tool_use_id":"toolu_01P2cZM7RgHucprMdpg8ANqX"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"ca2d1736-7177-428e-8ad2-6bc574478984","tool_use_result":"Error: File content (16655 tokens) exceeds maximum allowed tokens (10000). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file."}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Y7y1tyQp9eh3T1Ci5HkH4F","type":"tool_result","content":"     1→import { Test, TestingModule } from '@nestjs/testing';\n     2→import { DirectiveService } from './directive.service';\n     3→import { TaskStateService } from '../task-state';\n     4→import { DispatchService } from '../dispatch';\n     5→import { InternalAdapterService } from '../internal-adapter';\n     6→import {\n     7→  PLATFORM_PROVIDER,\n     8→  PlatformProvider,\n     9→} from '../platform/platform-provider.interface';\n    10→import { EventService } from '../event/event.service';\n    11→import { PhaseHooksService } from '../hooks/phase-hooks.service';\n    12→import { ArtefactsService } from '../artefacts/artefacts.service';\n    13→import { TaskEntity } from '../database/entities';\n    14→\n    15→function buildFakeTask(overrides?: Partial<TaskEntity>): TaskEntity {\n    16→  const task = new TaskEntity();\n    17→  task.id = 1;\n    18→  task.issue = 18;\n    19→  task.repo = 'AgentSDE/agent-core';\n    20→  task.directive = 'auto';\n    21→  task.currentPhase = 'refine';\n    22→  task.status = 'active';\n    23→  task.gatePhase = null;\n    24→  task.gateReason = null;\n    25→  task.prNumber = null;\n    26→  task.prBranch = null;\n    27→  task.phaseRefine = 'pending';\n    28→  task.phasePlan = 'pending';\n    29→  task.phaseExecute = 'pending';\n    30→  task.phaseReview = 'pending';\n    31→  task.phaseDeliver = 'pending';\n    32→  task.phaseCompound = 'pending';\n    33→  task.qualityGateOverride = false;\n    34→  task.replanAttempt = 0;\n    35→  task.createdAt = new Date();\n    36→  task.updatedAt = new Date();\n    37→  Object.assign(task, overrides);\n    38→  return task;\n    39→}\n    40→\n    41→describe('DirectiveService', () => {\n    42→  let service: DirectiveService;\n    43→  let taskStateService: jest.Mocked<\n    44→    Pick<\n    45→      TaskStateService,\n    46→      | 'findByIssueAndRepo'\n    47→      | 'save'\n    48→      | 'clearGate'\n    49→      | 'resetForRetry'\n    50→      | 'resetForRestart'\n    51→      | 'resetForReplan'\n    52→      | 'setQualityGateOverride'\n    53→    >\n    54→  >;\n    55→  let dispatchService: jest.Mocked<Pick<DispatchService, 'dispatch'>>;\n    56→  let internalAdapterService: jest.Mocked<\n    57→    Pick<InternalAdapterService, 'enqueuePhase'>\n    58→  >;\n    59→  let platformProvider: jest.Mocked<\n    60→    Pick<PlatformProvider, 'closePR' | 'deleteBranch' | 'postComment'>\n    61→  >;\n    62→  let eventService: jest.Mocked<Pick<EventService, 'record'>>;\n    63→  let phaseHooksService: jest.Mocked<\n    64→    Pick<PhaseHooksService, 'cleanupLabelsForReplan'>\n    65→  >;\n    66→  let artefactsService: jest.Mocked<\n    67→    Pick<ArtefactsService, 'clearPlanArtefacts' | 'buildCentralTaskDir'>\n    68→  >;\n    69→\n    70→  beforeEach(async () => {\n    71→    taskStateService = {\n    72→      findByIssueAndRepo: jest.fn().mockResolvedValue(buildFakeTask()),\n    73→      save: jest.fn().mockImplementation((t: TaskEntity) => Promise.resolve(t)),\n    74→      clearGate: jest.fn(),\n    75→      resetForRetry: jest.fn(),\n    76→      resetForRestart: jest.fn(),\n    77→      resetForReplan: jest.fn(),\n    78→      setQualityGateOverride: jest.fn(),\n    79→    };\n    80→\n    81→    artefactsService = {\n    82→      clearPlanArtefacts: jest.fn().mockResolvedValue(undefined),\n    83→      buildCentralTaskDir: jest\n    84→        .fn()\n    85→        .mockReturnValue('/tmp/tasks/AgentSDE/agent-core/gh-18'),\n    86→    };\n    87→\n    88→    dispatchService = {\n    89→      dispatch: jest.fn().mockResolvedValue(null),\n    90→    };\n    91→\n    92→    internalAdapterService = {\n    93→      enqueuePhase: jest.fn().mockResolvedValue(undefined),\n    94→    };\n    95→\n    96→    platformProvider = {\n    97→      closePR: jest.fn().mockResolvedValue(undefined),\n    98→      deleteBranch: jest.fn().mockResolvedValue(undefined),\n    99→      postComment: jest.fn().mockResolvedValue(undefined),\n   100→    };\n   101→\n   102→    eventService = {\n   103→      record: jest.fn().mockResolvedValue(undefined),\n   104→    };\n   105→\n   106→    phaseHooksService = {\n   107→      cleanupLabelsForReplan: jest.fn().mockResolvedValue(undefined),\n   108→    };\n   109→\n   110→    const module: TestingModule = await Test.createTestingModule({\n   111→      providers: [\n   112→        DirectiveService,\n   113→        { provide: TaskStateService, useValue: taskStateService },\n   114→        { provide: DispatchService, useValue: dispatchService },\n   115→        { provide: InternalAdapterService, useValue: internalAdapterService },\n   116→        { provide: PLATFORM_PROVIDER, useValue: platformProvider },\n   117→        { provide: EventService, useValue: eventService },\n   118→        { provide: PhaseHooksService, useValue: phaseHooksService },\n   119→        { provide: ArtefactsService, useValue: artefactsService },\n   120→      ],\n   121→    }).compile();\n   122→\n   123→    service = module.get<DirectiveService>(DirectiveService);\n   124→  });\n   125→\n   126→  it('should be defined', () => {\n   127→    expect(service).toBeDefined();\n   128→  });\n   129→\n   130→  it('auto: sets directive and dispatches', async () => {\n   131→    await service.applyDirective(\n   132→      18,\n   133→      'auto',\n   134→      'AgentSDE/agent-core',\n   135→      123,\n   136→      'zhjarvis',\n   137→    );\n   138→    expect(taskStateService.findByIssueAndRepo).toHaveBeenCalledWith(\n   139→      18,\n   140→      'AgentSDE/agent-core',\n   141→    );\n   142→    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n   143→    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n   144→    expect(taskStateService.clearGate).not.toHaveBeenCalled();\n   145→  });\n   146→\n   147→  it('quick: sets directive and dispatches', async () => {\n   148→    await service.applyDirective(\n   149→      18,\n   150→      'quick',\n   151→      'AgentSDE/agent-core',\n   152→      123,\n   153→      'zhjarvis',\n   154→    );\n   155→    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n   156→    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n   157→  });\n   158→\n   159→  it('careful: sets directive and dispatches', async () => {\n   160→    await service.applyDirective(\n   161→      18,\n   162→      'careful',\n   163→      'AgentSDE/agent-core',\n   164→      123,\n   165→      'zhjarvis',\n   166→    );\n   167→    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n   168→    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n   169→  });\n   170→\n   171→  describe('refine directive', () => {\n   172→    it('refine on gated task: clears gate, enqueues refine phase, posts confirmation', async () => {\n   173→      const gatedTask = buildFakeTask({\n   174→        status: 'gated',\n   175→        gatePhase: 'plan',\n   176→        gateReason: 'awaiting approval',\n   177→        directive: 'careful',\n   178→      });\n   179→      taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n   180→\n   181→      await service.applyDirective(\n   182→        18,\n   183→        'refine',\n   184→        'AgentSDE/agent-core',\n   185→        123,\n   186→        'zhjarvis',\n   187→      );\n   188→\n   189→      expect(taskStateService.clearGate).toHaveBeenCalledWith(gatedTask);\n   190→      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n   191→      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n   192→        18,\n   193→        'AgentSDE/agent-core',\n   194→        'refine',\n   195→        'refine directive',\n   196→      );\n   197→      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n   198→      expect(platformProvider.postComment).toHaveBeenCalledWith(\n   199→        'AgentSDE',\n   200→        'agent-core',\n   201→        18,\n   202→        expect.any(String),\n   203→      );\n   204→    });\n   205→\n   206→    it('refine on active task: posts rejection comment, no dispatch/enqueue', async () => {\n   207→      const activeTask = buildFakeTask({ status: 'active' });\n   208→      taskStateService.findByIssueAndRepo.mockResolvedValue(activeTask);\n   209→\n   210→      await service.applyDirective(\n   211→        18,\n   212→        'refine',\n   213→        'AgentSDE/agent-core',\n   214→        123,\n   215→        'zhjarvis',\n   216→      );\n   217→\n   218→      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n   219→      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n   220→      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n   221→      expect(platformProvider.postComment).toHaveBeenCalledWith(\n   222→        'AgentSDE',\n   223→        'agent-core',\n   224→        18,\n   225→        expect.any(String),\n   226→      );\n   227→    });\n   228→\n   229→    it('refine on succeeded task: posts terminal rejection comment, no dispatch/enqueue', async () => {\n   230→      const succeededTask = buildFakeTask({ status: 'succeeded' });\n   231→      taskStateService.findByIssueAndRepo.mockResolvedValue(succeededTask);\n   232→\n   233→      await service.applyDirective(\n   234→        18,\n   235→        'refine',\n   236→        'AgentSDE/agent-core',\n   237→        123,\n   238→        'zhjarvis',\n   239→      );\n   240→\n   241→      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n   242→      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n   243→      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n   244→      expect(platformProvider.postComment).toHaveBeenCalledWith(\n   245→        'AgentSDE',\n   246→        'agent-core',\n   247→        18,\n   248→        expect.any(String),\n   249→      );\n   250→    });\n   251→\n   252→    it('refine on failed task: posts terminal rejection comment, no dispatch/enqueue', async () => {\n   253→      const failedTask = buildFakeTask({ status: 'failed' });\n   254→      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n   255→\n   256→      await service.applyDirective(\n   257→        18,\n   258→        'refine',\n   259→        'AgentSDE/agent-core',\n   260→        123,\n   261→        'zhjarvis',\n   262→      );\n   263→\n   264→      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n   265→      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n   266→      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n   267→    });\n   268→\n   269→    it('refine does not overwrite task.directive', async () => {\n   270→      const gatedTask = buildFakeTask({\n   271→        status: 'gated',\n   272→        gatePhase: 'plan',\n   273→        directive: 'careful',\n   274→      });\n   275→      taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n   276→\n   277→      await service.applyDirective(\n   278→        18,\n   279→        'refine',\n   280→        'AgentSDE/agent-core',\n   281→        123,\n   282→        'zhjarvis',\n   283→      );\n   284→\n   285→      expect(gatedTask.directive).toBe('careful');\n   286→    });\n   287→  });\n   288→\n   289→  it('approve: dispatches directive without pre-clearing gate (gate cleared by phase-router)', async () => {\n   290→    const gatedTask = buildFakeTask({\n   291→      gatePhase: 'review',\n   292→      gateReason: 'needs approval',\n   293→      status: 'gated',\n   294→    });\n   295→    taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n   296→\n   297→    await service.applyDirective(\n   298→      18,\n   299→      'approve',\n   300→      'AgentSDE/agent-core',\n   301→      123,\n   302→      'zhjarvis',\n   303→    );\n   304→    // Gate must NOT be cleared here — phase-router reads gatePhase in resolvePhase\n   305→    // to determine which phase to resume. Pre-clearing leaves gatePhase=null and\n   306→    // the router returns null, leaving the task stuck at status=gated.\n   307→    expect(taskStateService.clearGate).not.toHaveBeenCalled();\n   308→    expect(gatedTask.gatePhase).toBe('review'); // preserved for phase-router\n   309→    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n   310→  });\n   311→\n   312→  it('stop: sets status=paused and does NOT dispatch', async () => {\n   313→    await service.applyDirective(\n   314→      18,\n   315→      'stop',\n   316→      'AgentSDE/agent-core',\n   317→      123,\n   318→      'zhjarvis',\n   319→    );\n   320→    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n   321→    expect(dispatchService.dispatch).not.toHaveBeenCalled();\n   322→  });\n   323→\n   324→  it('posts a GitHub confirmation comment for all directives', async () => {\n   325→    await service.applyDirective(\n   326→      18,\n   327→      'auto',\n   328→      'AgentSDE/agent-core',\n   329→      123,\n   330→      'zhjarvis',\n   331→    );\n   332→    expect(platformProvider.postComment).toHaveBeenCalledWith(\n   333→      'AgentSDE',\n   334→      'agent-core',\n   335→      18,\n   336→      expect.any(String),\n   337→    );\n   338→  });\n   339→\n   340→  it('returns early if no task found', async () => {\n   341→    taskStateService.findByIssueAndRepo.mockResolvedValue(null);\n   342→    await service.applyDirective(\n   343→      18,\n   344→      'auto',\n   345→      'AgentSDE/agent-core',\n   346→      123,\n   347→      'zhjarvis',\n   348→    );\n   349→    expect(taskStateService.save).not.toHaveBeenCalled();\n   350→    expect(dispatchService.dispatch).not.toHaveBeenCalled();\n   351→  });\n   352→\n   353→  describe('retry directive', () => {\n   354→    it('retry on stuck task: resets phase, enqueues phase, posts confirmation', async () => {\n   355→      const stuckTask = buildFakeTask({\n   356→        status: 'stuck',\n   357→        currentPhase: 'execute',\n   358→        phaseExecute: 'active',\n   359→      });\n   360→      taskStateService.findByIssueAndRepo.mockResolvedValue(stuckTask);\n   361→\n   362→      await service.applyDirective(\n   363→        18,\n   364→        'retry',\n   365→        'AgentSDE/agent-core',\n   366→        123,\n   367→        'zhjarvis',\n   368→      );\n   369→\n   370→      expect(taskStateService.resetForRetry).toHaveBeenCalledWith(stuckTask);\n   371→      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n   372→      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n   373→        18,\n   374→        'AgentSDE/agent-core',\n   375→        'execute',\n   376→        'retry directive',\n   377→      );\n   378→      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n   379→      expect(platformProvider.postComment).toHaveBeenCalled();\n   380→    });\n   381→\n   382→    it('retry on blocked task: resets phase, enqueues phase', async () => {\n   383→      const blockedTask = buildFakeTask({\n   384→        status: 'blocked',\n   385→        currentPhase: 'plan',\n   386→      });\n   387→      taskStateService.findByIssueAndRepo.mockResolvedValue(blockedTask);\n   388→\n   389→      await service.applyDirective(\n   390→        18,\n   391→        'retry',\n   392→        'AgentSDE/agent-core',\n   393→        123,\n   394→        'zhjarvis',\n   395→      );\n   396→\n   397→      expect(taskStateService.resetForRetry).toHaveBeenCalledWith(blockedTask);\n   398→      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n   399→        18,\n   400→        'AgentSDE/agent-core',\n   401→        'plan',\n   402→        'retry directive',\n   403→      );\n   404→    });\n   405→\n   406→    it('retry on succeeded task: posts rejection comment, does not dispatch', async () => {\n   407→      const succeededTask = buildFakeTask({ status: 'succeeded' });\n   408→      taskStateService.findByIssueAndRepo.mockResolvedValue(succeededTask);\n   409→\n   410→      await service.applyDirective(\n   411→        18,\n   412→        'retry',\n   413→        'AgentSDE/agent-core',\n   414→        123,\n   415→        'zhjarvis',\n   416→      );\n   417→\n   418→      expect(taskStateService.resetForRetry).not.toHaveBeenCalled();\n   419→      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n   420→      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n   421→      // rejection comment posted\n   422→      expect(platformProvider.postComment).toHaveBeenCalledWith(\n   423→        'AgentSDE',\n   424→        'agent-core',\n   425→        18,\n   426→        expect.any(String),\n   427→      );\n   428→    });\n   429→\n   430→    it('retry on failed task: posts rejection comment, does not dispatch', async () => {\n   431→      const failedTask = buildFakeTask({ status: 'failed' });\n   432→      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n   433→\n   434→      await service.applyDirective(\n   435→        18,\n   436→        'retry',\n   437→        'AgentSDE/agent-core',\n   438→        123,\n   439→        'zhjarvis',\n   440→      );\n   441→\n   442→      expect(taskStateService.resetForRetry).not.toHaveBeenCalled();\n   443→      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n   444→    });\n   445→\n   446→    it('retry does not overwrite task.directive', async () => {\n   447→      const task = buildFakeTask({ directive: 'careful', status: 'stuck' });\n   448→      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n   449→\n   450→      await service.applyDirective(\n   451→        18,\n   452→        'retry',\n   453→        'AgentSDE/agent-core',\n   454→        123,\n   455→        'zhjarvis',\n   456→      );\n   457→\n   458→      expect(task.directive).toBe('careful');\n   459→    });\n   460→  });\n   461→\n   462→  describe('skip-quality-gate directive', () => {\n   463→    it('sets qualityGateOverride=true on the task and posts confirmation', async () => {\n   464→      const task = buildFakeTask({ status: 'gated', currentPhase: 'deliver' });\n   465→      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n   466→\n   467→      await service.applyDirective(\n   468→        18,\n   469→        'skip-quality-gate',\n   470→        'AgentSDE/agent-core',\n   471→        123,\n   472→        'zhjarvis',\n   473→      );\n   474→\n   475→      expect(taskStateService.setQualityGateOverride).toHaveBeenCalledWith(\n   476→        task,\n   477→        true,\n   478→      );\n   479→      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n   480→      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n   481→      expect(platformProvider.postComment).toHaveBeenCalledWith(\n   482→        'AgentSDE',\n   483→        'agent-core',\n   484→        18,\n   485→        expect.any(String),\n   486→      );\n   487→    });\n   488→\n   489→    it('does not dispatch a directive event', async () => {\n   490→      await service.applyDirective(\n   491→        18,\n   492→        'skip-quality-gate',\n   493→        'AgentSDE/agent-core',\n   494→        123,\n   495→        'zhjarvis',\n   496→      );\n   497→      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n   498→    });\n   499→  });\n   500→\n   501→  describe('restart directive', () => {\n   502→    it('restart on non-active task: resets all phases, clears PR state, enqueues refine, posts confirmation', async () => {\n   503→      const failedTask = buildFakeTask({\n   504→        status: 'failed',\n   505→        currentPhase: 'execute',\n   506→        prNumber: null,\n   507→        prBranch: null,\n   508→      });\n   509→      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n   510→\n   511→      await service.applyDirective(\n   512→        18,\n   513→        'restart',\n   514→        'AgentSDE/agent-core',\n   515→        123,\n   516→        'zhjarvis',\n   517→      );\n   518→\n   519→      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(failedTask);\n   520→      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n   521→      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n   522→        18,\n   523→        'AgentSDE/agent-core',\n   524→        'refine',\n   525→        'restart directive',\n   526→      );\n   527→      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n   528→      expect(platformProvider.postComment).toHaveBeenCalled();\n   529→    });\n   530→\n   531→    it('restart on stuck task: resets all phases, enqueues refine', async () => {\n   532→      const stuckTask = buildFakeTask({ status: 'stuck', prNumber: null });\n   533→      taskStateService.findByIssueAndRepo.mockResolvedValue(stuckTask);\n   534→\n   535→      await service.applyDirective(\n   536→        18,\n   537→        'restart',\n   538→        'AgentSDE/agent-core',\n   539→        123,\n   540→        'zhjarvis',\n   541→      );\n   542→\n   543→      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(stuckTask);\n   544→      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n   545→        18,\n   546→        'AgentSDE/agent-core',\n   547→        'refine',\n   548→        'restart directive',\n   549→      );\n   550→    });\n   551→\n   552→    it('restart with open PR: posts superseded comment, closes PR, clears prNumber/prBranch', async () => {\n   553→      const task = buildFakeTask({\n   554→        status: 'blocked',\n   555→        prNumber: 77,\n   556→        prBranch: 'feat/77-feature',\n   557→      });\n   558→      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n   559→\n   560→      await service.applyDirective(\n   561→        18,\n   562→        'restart',\n   563→        'AgentSDE/agent-core',\n   564→        123,\n   565→        'zhjarvis',\n   566→      );\n   567→\n   568→      // First postComment call should be the superseded comment on the PR\n   569→      expect(platformProvider.postComment).toHaveBeenNthCalledWith(\n   570→        1,\n   571→        'AgentSDE',\n   572→        'agent-core',\n   573→        77,\n   574→        expect.any(String),\n   575→      );\n   576→      expect(platformProvider.closePR).toHaveBeenCalledWith(\n   577→        'AgentSDE',\n   578→        'agent-core',\n   579→        77,\n   580→      );\n   581→      expect(platformProvider.deleteBranch).toHaveBeenCalledWith(\n   582→        'AgentSDE',\n   583→        'agent-core',\n   584→        'feat/77-feature',\n   585→      );\n   586→      expect(task.prNumber).toBeNull();\n   587→      expect(task.prBranch).toBeNull();\n   588→      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(task);\n   589→      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n   590→        18,\n   591→        'AgentSDE/agent-core',\n   592→        'refine',\n   593→        'restart directive',\n   594→      );\n   595→    });\n   596→\n   597→    it('restart: PR close failure logs warning and proceeds', async () => {\n   598→      const task = buildFakeTask({\n   599→        status: 'blocked',\n   600→        prNumber: 88,\n   601→        prBranch: 'feat/88-thing',\n   602→      });\n   603→      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n   604→      (platformProvider.closePR as jest.Mock).mockRejectedValueOnce(\n   605→        new Error('GitHub API error: 500'),\n   606→      );\n   607→\n   608→      await service.applyDirective(\n   609→        18,\n   610→        'restart',\n   611→        'AgentSDE/agent-core',\n   612→        123,\n   613→        'zhjarvis',\n   614→      );\n   615→\n   616→      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(task);\n   617→      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n   618→        18,\n   619→        'AgentSDE/agent-core',\n   620→        'refine',\n   621→        'restart directive',\n   622→      );\n   623→    });\n   624→\n   625→    it('restart on active task: posts rejection comment, does not dispatch', async () => {\n   626→      const activeTask = buildFakeTask({ status: 'active' });\n   627→      taskStateService.findByIssueAndRepo.mockResolvedValue(activeTask);\n   628→\n   629→      await service.applyDirective(\n   630→        18,\n   631→        'restart',\n   632→        'AgentSDE/agent-core',\n   633→        123,\n   634→        'zhjarvis',\n   635→      );\n   636→\n   637→      expect(taskStateService.resetForRestart).not.toHaveBeenCalled();\n   638→      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n   639→      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n   640→      // rejection comment posted\n   641→      expect(platformProvider.postComment).toHaveBeenCalledWith(\n   642→        'AgentSDE',\n   643→        'agent-core',\n   644→        18,\n   645→        expect.any(String),\n   646→      );\n   647→    });\n   648→\n   649→    it('restart does not overwrite task.directive', async () => {\n   650→      const task = buildFakeTask({ directive: 'careful', status: 'blocked' });\n   651→      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n   652→\n   653→      await service.applyDirective(\n   654→        18,\n   655→        'restart',\n   656→        'AgentSDE/agent-core',\n   657→        123,\n   658→        'zhjarvis',\n   659→      );\n   660→\n   661→      expect(task.directive).toBe('careful');\n   662→    });\n   663→  });\n   664→\n   665→  describe('replan directive', () => {\n   666→    it('normal replan: uses resetForReplan, closes PR, deletes branch, clears artefacts, enqueues refine', async () => {\n   667→      const task = buildFakeTask({\n   668→        status: 'blocked',\n   669→        currentPhase: 'execute',\n   670→        prNumber: 42,\n   671→        prBranch: 'feat/42-my-feature',\n   672→        replanAttempt: 0,\n   673→      });\n   674→      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n   675→      (taskStateService.resetForReplan as jest.Mock).mockImplementation(\n   676→        (t: TaskEntity): void => {\n   677→          t.prNumber = null;\n   678→          t.prBranch = null;\n   679→          t.replanAttempt = (t.replanAttempt ?? 0) + 1;\n   680→        },\n   681→      );\n   682→\n   683→      await service.applyDirective(\n   684→        18,\n   685→        'replan',\n   686→        'AgentSDE/agent-core',\n   687→        123,\n   688→        'zhjarvis',\n   689→      );\n   690→\n   691→      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n   692→      expect(taskStateService.resetForRestart).not.toHaveBeenCalled();\n   693→      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n   694→      expect(platformProvider.closePR).toHaveBeenCalledWith(\n   695→        'AgentSDE',\n   696→        'agent-core',\n   697→        42,\n   698→      );\n   699→      expect(platformProvider.deleteBranch).toHaveBeenCalledWith(\n   700→        'AgentSDE',\n   701→        'agent-core',\n   702→        'feat/42-my-feature',\n   703→      );\n   704→      expect(artefactsService.clearPlanArtefacts).toHaveBeenCalledWith(\n   705→        '/tmp/tasks/AgentSDE/agent-core/gh-18',\n   706→      );\n   707→      expect(eventService.record).toHaveBeenCalledWith(\n   708→        task.id,\n   709→        'replan_triggered',\n   710→        'directive-service',\n   711→        expect.objectContaining({ actor: 'zhjarvis', replan_attempt: 1 }),\n   712→      );\n   713→      expect(phaseHooksService.cleanupLabelsForReplan).toHaveBeenCalledWith(\n   714→        'AgentSDE',\n   715→        'agent-core',\n   716→        18,\n   717→      );\n   718→      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n   719→        18,\n   720→        'AgentSDE/agent-core',\n   721→        'refine',\n   722→        'replan directive',\n   723→      );\n   724→      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n   725→      expect(platformProvider.postComment).toHaveBeenCalled();\n   726→    });\n   727→\n   728→    it('replan with no PR: skips PR close/branch delete, still clears artefacts', async () => {\n   729→      const task = buildFakeTask({\n   730→        status: 'stuck',\n   731→        currentPhase: 'plan',\n   732→        prNumber: null,\n   733→        prBranch: null,\n   734→      });\n   735→      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n   736→\n   737→      await service.applyDirective(\n   738→        18,\n   739→        'replan',\n   740→        'AgentSDE/agent-core',\n   741→        123,\n   742→        'zhjarvis',\n   743→      );\n   744→\n   745→      expect(platformProvider.closePR).not.toHaveBeenCalled();\n   746→      expect(platformProvider.deleteBranch).not.toHaveBeenCalled();\n   747→      expect(artefactsService.clearPlanArtefacts).toHaveBeenCalled();\n   748→      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n   749→      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n   750→        18,\n   751→        'AgentSDE/agent-core',\n   752→        'refine',\n   753→        'replan directive',\n   754→      );\n   755→    });\n   756→\n   757→    it('replan already in active refine: posts no-op comment, does not enqueue', async () => {\n   758→      const task = buildFakeTask({\n   759→        status: 'active',\n   760→        currentPhase: 'refine',\n   761→      });\n   762→      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n   763→\n   764→      await service.applyDirective(\n   765→        18,\n   766→        'replan',\n   767→        'AgentSDE/agent-core',\n   768→        123,\n   769→        'zhjarvis',\n   770→      );\n   771→\n   772→      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n   773→      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n   774→      expect(platformProvider.postComment).toHaveBeenCalledWith(\n   775→        'AgentSDE',\n   776→        'agent-core',\n   777→        18,\n   778→        expect.any(String),\n   779→      );\n   780→    });\n   781→\n   782→    it('replan on succeeded task: posts rejection comment, does not enqueue', async () => {\n   783→      const task = buildFakeTask({ status: 'succeeded' });\n   784→      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n   785→\n   786→      await service.applyDirective(\n   787→        18,\n   788→        'replan',\n   789→        'AgentSDE/agent-core',\n   790→        123,\n   791→        'zhjarvis',\n   792→      );\n   793→\n   794→      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n   795→      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n   796→      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n   797→      expect(platformProvider.postComment).toHaveBeenCalledWith(\n   798→        'AgentSDE',\n   799→        'agent-core',\n   800→        18,\n   801→        expect.any(String),\n   802→      );\n   803→    });\n   804→\n   805→    it('replan on failed task: posts rejection comment, does not enqueue', async () => {\n   806→      const task = buildFakeTask({ status: 'failed' });\n   807→      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n   808→\n   809→      await service.applyDirective(\n   810→        18,\n   811→        'replan',\n   812→        'AgentSDE/agent-core',\n   813→        123,\n   814→        'zhjarvis',\n   815→      );\n   816→\n   817→      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n   818→      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n   819→    });\n   820→\n   821→    it('replan: PR close failure logs warning and proceeds', async () => {\n   822→      const task = buildFakeTask({\n   823→        status: 'blocked',\n   824→        prNumber: 99,\n   825→        prBranch: 'feat/99-branch',\n   826→      });\n   827→      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n   828→      (platformProvider.closePR as jest.Mock).mockRejectedValueOnce(\n   829→        new Error('GitHub API error: 500 — Internal Server Error'),\n   830→      );\n   831→\n   832→      await service.applyDirective(\n   833→        18,\n   834→        'replan',\n   835→        'AgentSDE/agent-core',\n   836→        123,\n   837→        'zhjarvis',\n   838→      );\n   839→\n   840→      // Should still reset and enqueue even when PR close fails\n   841→      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n   842→      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n   843→        18,\n   844→        'AgentSDE/agent-core',\n   845→        'refine',\n   846→        'replan directive',\n   847→      );\n   848→    });\n   849→\n   850→    it('replan: posts superseded comment on open PR before closing', async () => {\n   851→      const task = buildFakeTask({\n   852→        status: 'blocked',\n   853→        prNumber: 55,\n   854→        prBranch: 'feat/55-thing',\n   855→      });\n   856→      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n   857→\n   858→      await service.applyDirective(\n   859→        18,\n   860→        'replan',\n   861→        'AgentSDE/agent-core',\n   862→        123,\n   863→        'zhjarvis',\n   864→      );\n   865→\n   866→      // First postComment call should be the superseded comment on the PR\n   867→      expect(platformProvider.postComment).toHaveBeenNthCalledWith(\n   868→        1,\n   869→        'AgentSDE',\n   870→        'agent-core',\n   871→        55,\n   872→        expect.any(String),\n   873→      );\n   874→      expect(platformProvider.closePR).toHaveBeenCalledWith(\n   875→        'AgentSDE',\n   876→        'agent-core',\n   877→        55,\n   878→      );\n   879→    });\n   880→  });\n   881→});\n   882→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"1f808039-eb92-46a2-99ac-2b6858a6c309","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/directive/directive.service.spec.ts","content":"import { Test, TestingModule } from '@nestjs/testing';\nimport { DirectiveService } from './directive.service';\nimport { TaskStateService } from '../task-state';\nimport { DispatchService } from '../dispatch';\nimport { InternalAdapterService } from '../internal-adapter';\nimport {\n  PLATFORM_PROVIDER,\n  PlatformProvider,\n} from '../platform/platform-provider.interface';\nimport { EventService } from '../event/event.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { TaskEntity } from '../database/entities';\n\nfunction buildFakeTask(overrides?: Partial<TaskEntity>): TaskEntity {\n  const task = new TaskEntity();\n  task.id = 1;\n  task.issue = 18;\n  task.repo = 'AgentSDE/agent-core';\n  task.directive = 'auto';\n  task.currentPhase = 'refine';\n  task.status = 'active';\n  task.gatePhase = null;\n  task.gateReason = null;\n  task.prNumber = null;\n  task.prBranch = null;\n  task.phaseRefine = 'pending';\n  task.phasePlan = 'pending';\n  task.phaseExecute = 'pending';\n  task.phaseReview = 'pending';\n  task.phaseDeliver = 'pending';\n  task.phaseCompound = 'pending';\n  task.qualityGateOverride = false;\n  task.replanAttempt = 0;\n  task.createdAt = new Date();\n  task.updatedAt = new Date();\n  Object.assign(task, overrides);\n  return task;\n}\n\ndescribe('DirectiveService', () => {\n  let service: DirectiveService;\n  let taskStateService: jest.Mocked<\n    Pick<\n      TaskStateService,\n      | 'findByIssueAndRepo'\n      | 'save'\n      | 'clearGate'\n      | 'resetForRetry'\n      | 'resetForRestart'\n      | 'resetForReplan'\n      | 'setQualityGateOverride'\n    >\n  >;\n  let dispatchService: jest.Mocked<Pick<DispatchService, 'dispatch'>>;\n  let internalAdapterService: jest.Mocked<\n    Pick<InternalAdapterService, 'enqueuePhase'>\n  >;\n  let platformProvider: jest.Mocked<\n    Pick<PlatformProvider, 'closePR' | 'deleteBranch' | 'postComment'>\n  >;\n  let eventService: jest.Mocked<Pick<EventService, 'record'>>;\n  let phaseHooksService: jest.Mocked<\n    Pick<PhaseHooksService, 'cleanupLabelsForReplan'>\n  >;\n  let artefactsService: jest.Mocked<\n    Pick<ArtefactsService, 'clearPlanArtefacts' | 'buildCentralTaskDir'>\n  >;\n\n  beforeEach(async () => {\n    taskStateService = {\n      findByIssueAndRepo: jest.fn().mockResolvedValue(buildFakeTask()),\n      save: jest.fn().mockImplementation((t: TaskEntity) => Promise.resolve(t)),\n      clearGate: jest.fn(),\n      resetForRetry: jest.fn(),\n      resetForRestart: jest.fn(),\n      resetForReplan: jest.fn(),\n      setQualityGateOverride: jest.fn(),\n    };\n\n    artefactsService = {\n      clearPlanArtefacts: jest.fn().mockResolvedValue(undefined),\n      buildCentralTaskDir: jest\n        .fn()\n        .mockReturnValue('/tmp/tasks/AgentSDE/agent-core/gh-18'),\n    };\n\n    dispatchService = {\n      dispatch: jest.fn().mockResolvedValue(null),\n    };\n\n    internalAdapterService = {\n      enqueuePhase: jest.fn().mockResolvedValue(undefined),\n    };\n\n    platformProvider = {\n      closePR: jest.fn().mockResolvedValue(undefined),\n      deleteBranch: jest.fn().mockResolvedValue(undefined),\n      postComment: jest.fn().mockResolvedValue(undefined),\n    };\n\n    eventService = {\n      record: jest.fn().mockResolvedValue(undefined),\n    };\n\n    phaseHooksService = {\n      cleanupLabelsForReplan: jest.fn().mockResolvedValue(undefined),\n    };\n\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        DirectiveService,\n        { provide: TaskStateService, useValue: taskStateService },\n        { provide: DispatchService, useValue: dispatchService },\n        { provide: InternalAdapterService, useValue: internalAdapterService },\n        { provide: PLATFORM_PROVIDER, useValue: platformProvider },\n        { provide: EventService, useValue: eventService },\n        { provide: PhaseHooksService, useValue: phaseHooksService },\n        { provide: ArtefactsService, useValue: artefactsService },\n      ],\n    }).compile();\n\n    service = module.get<DirectiveService>(DirectiveService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('auto: sets directive and dispatches', async () => {\n    await service.applyDirective(\n      18,\n      'auto',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.findByIssueAndRepo).toHaveBeenCalledWith(\n      18,\n      'AgentSDE/agent-core',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n    expect(taskStateService.clearGate).not.toHaveBeenCalled();\n  });\n\n  it('quick: sets directive and dispatches', async () => {\n    await service.applyDirective(\n      18,\n      'quick',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n  });\n\n  it('careful: sets directive and dispatches', async () => {\n    await service.applyDirective(\n      18,\n      'careful',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n  });\n\n  describe('refine directive', () => {\n    it('refine on gated task: clears gate, enqueues refine phase, posts confirmation', async () => {\n      const gatedTask = buildFakeTask({\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'awaiting approval',\n        directive: 'careful',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).toHaveBeenCalledWith(gatedTask);\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'refine directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('refine on active task: posts rejection comment, no dispatch/enqueue', async () => {\n      const activeTask = buildFakeTask({ status: 'active' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(activeTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('refine on succeeded task: posts terminal rejection comment, no dispatch/enqueue', async () => {\n      const succeededTask = buildFakeTask({ status: 'succeeded' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(succeededTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('refine on failed task: posts terminal rejection comment, no dispatch/enqueue', async () => {\n      const failedTask = buildFakeTask({ status: 'failed' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n    });\n\n    it('refine does not overwrite task.directive', async () => {\n      const gatedTask = buildFakeTask({\n        status: 'gated',\n        gatePhase: 'plan',\n        directive: 'careful',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(gatedTask.directive).toBe('careful');\n    });\n  });\n\n  it('approve: dispatches directive without pre-clearing gate (gate cleared by phase-router)', async () => {\n    const gatedTask = buildFakeTask({\n      gatePhase: 'review',\n      gateReason: 'needs approval',\n      status: 'gated',\n    });\n    taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n\n    await service.applyDirective(\n      18,\n      'approve',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    // Gate must NOT be cleared here — phase-router reads gatePhase in resolvePhase\n    // to determine which phase to resume. Pre-clearing leaves gatePhase=null and\n    // the router returns null, leaving the task stuck at status=gated.\n    expect(taskStateService.clearGate).not.toHaveBeenCalled();\n    expect(gatedTask.gatePhase).toBe('review'); // preserved for phase-router\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n  });\n\n  it('stop: sets status=paused and does NOT dispatch', async () => {\n    await service.applyDirective(\n      18,\n      'stop',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).not.toHaveBeenCalled();\n  });\n\n  it('posts a GitHub confirmation comment for all directives', async () => {\n    await service.applyDirective(\n      18,\n      'auto',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(platformProvider.postComment).toHaveBeenCalledWith(\n      'AgentSDE',\n      'agent-core',\n      18,\n      expect.any(String),\n    );\n  });\n\n  it('returns early if no task found', async () => {\n    taskStateService.findByIssueAndRepo.mockResolvedValue(null);\n    await service.applyDirective(\n      18,\n      'auto',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(dispatchService.dispatch).not.toHaveBeenCalled();\n  });\n\n  describe('retry directive', () => {\n    it('retry on stuck task: resets phase, enqueues phase, posts confirmation', async () => {\n      const stuckTask = buildFakeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(stuckTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).toHaveBeenCalledWith(stuckTask);\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'execute',\n        'retry directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalled();\n    });\n\n    it('retry on blocked task: resets phase, enqueues phase', async () => {\n      const blockedTask = buildFakeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(blockedTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).toHaveBeenCalledWith(blockedTask);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'plan',\n        'retry directive',\n      );\n    });\n\n    it('retry on succeeded task: posts rejection comment, does not dispatch', async () => {\n      const succeededTask = buildFakeTask({ status: 'succeeded' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(succeededTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      // rejection comment posted\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('retry on failed task: posts rejection comment, does not dispatch', async () => {\n      const failedTask = buildFakeTask({ status: 'failed' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n    });\n\n    it('retry does not overwrite task.directive', async () => {\n      const task = buildFakeTask({ directive: 'careful', status: 'stuck' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(task.directive).toBe('careful');\n    });\n  });\n\n  describe('skip-quality-gate directive', () => {\n    it('sets qualityGateOverride=true on the task and posts confirmation', async () => {\n      const task = buildFakeTask({ status: 'gated', currentPhase: 'deliver' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'skip-quality-gate',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        true,\n      );\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('does not dispatch a directive event', async () => {\n      await service.applyDirective(\n        18,\n        'skip-quality-gate',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('restart directive', () => {\n    it('restart on non-active task: resets all phases, clears PR state, enqueues refine, posts confirmation', async () => {\n      const failedTask = buildFakeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n        prNumber: null,\n        prBranch: null,\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(failedTask);\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on stuck task: resets all phases, enqueues refine', async () => {\n      const stuckTask = buildFakeTask({ status: 'stuck', prNumber: null });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(stuckTask);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(stuckTask);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n    });\n\n    it('restart with open PR: posts superseded comment, closes PR, clears prNumber/prBranch', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 77,\n        prBranch: 'feat/77-feature',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      // First postComment call should be the superseded comment on the PR\n      expect(platformProvider.postComment).toHaveBeenNthCalledWith(\n        1,\n        'AgentSDE',\n        'agent-core',\n        77,\n        expect.any(String),\n      );\n      expect(platformProvider.closePR).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        77,\n      );\n      expect(platformProvider.deleteBranch).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        'feat/77-feature',\n      );\n      expect(task.prNumber).toBeNull();\n      expect(task.prBranch).toBeNull();\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n    });\n\n    it('restart: PR close failure logs warning and proceeds', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 88,\n        prBranch: 'feat/88-thing',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n      (platformProvider.closePR as jest.Mock).mockRejectedValueOnce(\n        new Error('GitHub API error: 500'),\n      );\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n    });\n\n    it('restart on active task: posts rejection comment, does not dispatch', async () => {\n      const activeTask = buildFakeTask({ status: 'active' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(activeTask);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      // rejection comment posted\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('restart does not overwrite task.directive', async () => {\n      const task = buildFakeTask({ directive: 'careful', status: 'blocked' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(task.directive).toBe('careful');\n    });\n  });\n\n  describe('replan directive', () => {\n    it('normal replan: uses resetForReplan, closes PR, deletes branch, clears artefacts, enqueues refine', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n        prNumber: 42,\n        prBranch: 'feat/42-my-feature',\n        replanAttempt: 0,\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n      (taskStateService.resetForReplan as jest.Mock).mockImplementation(\n        (t: TaskEntity): void => {\n          t.prNumber = null;\n          t.prBranch = null;\n          t.replanAttempt = (t.replanAttempt ?? 0) + 1;\n        },\n      );\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n      expect(taskStateService.resetForRestart).not.toHaveBeenCalled();\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(platformProvider.closePR).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        42,\n      );\n      expect(platformProvider.deleteBranch).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        'feat/42-my-feature',\n      );\n      expect(artefactsService.clearPlanArtefacts).toHaveBeenCalledWith(\n        '/tmp/tasks/AgentSDE/agent-core/gh-18',\n      );\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'replan_triggered',\n        'directive-service',\n        expect.objectContaining({ actor: 'zhjarvis', replan_attempt: 1 }),\n      );\n      expect(phaseHooksService.cleanupLabelsForReplan).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n      );\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'replan directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalled();\n    });\n\n    it('replan with no PR: skips PR close/branch delete, still clears artefacts', async () => {\n      const task = buildFakeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n        prNumber: null,\n        prBranch: null,\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(platformProvider.closePR).not.toHaveBeenCalled();\n      expect(platformProvider.deleteBranch).not.toHaveBeenCalled();\n      expect(artefactsService.clearPlanArtefacts).toHaveBeenCalled();\n      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'replan directive',\n      );\n    });\n\n    it('replan already in active refine: posts no-op comment, does not enqueue', async () => {\n      const task = buildFakeTask({\n        status: 'active',\n        currentPhase: 'refine',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('replan on succeeded task: posts rejection comment, does not enqueue', async () => {\n      const task = buildFakeTask({ status: 'succeeded' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('replan on failed task: posts rejection comment, does not enqueue', async () => {\n      const task = buildFakeTask({ status: 'failed' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n    });\n\n    it('replan: PR close failure logs warning and proceeds', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 99,\n        prBranch: 'feat/99-branch',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n      (platformProvider.closePR as jest.Mock).mockRejectedValueOnce(\n        new Error('GitHub API error: 500 — Internal Server Error'),\n      );\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      // Should still reset and enqueue even when PR close fails\n      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'replan directive',\n      );\n    });\n\n    it('replan: posts superseded comment on open PR before closing', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 55,\n        prBranch: 'feat/55-thing',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      // First postComment call should be the superseded comment on the PR\n      expect(platformProvider.postComment).toHaveBeenNthCalledWith(\n        1,\n        'AgentSDE',\n        'agent-core',\n        55,\n        expect.any(String),\n      );\n      expect(platformProvider.closePR).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        55,\n      );\n    });\n  });\n});\n","numLines":882,"startLine":1,"totalLines":882}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_019s1QhNoKoMkdf9zsNmweLZ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now let me read the remaining spec files:","signature":"EuYBClsIDBgCKkACvDSGSDpSkskpyynGCm0lJKicZW7uUtb/Xb0p0pfjpWLVqFd54cRT0ONjistc56wOkG1vspmul9qssWm2i1KjMhFjbGF1ZGUtc29ubmV0LTQtNjgAEgwYVMEzQ/bJ/QrgaxMaDKG90ZO2XC2oG927QCIwZ9YaKZwVpcjcIr3ym7mMc8kySJw8mNJ3kBICQfNMXoBQeORUXjDCanvY79teSUkDKjmy9qkcOCaypUXSddN+oQDmSfdkX4WZ4hx6jBhDcly9YsCJQbpMw3DUNG7ezK5rdVxHf4dgS3T6MH0YAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":20260,"cache_read_input_tokens":31565,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":20260},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"c216a002-7350-4908-8686-bdf31ad57427"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_019s1QhNoKoMkdf9zsNmweLZ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018HtJgk3iefMMNYKFdZvRiq","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/hooks/phase-hooks.service.spec.ts"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":20260,"cache_read_input_tokens":31565,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":20260},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"51eb7b58-3d9e-4467-ab6d-49b7accd66b7"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018HtJgk3iefMMNYKFdZvRiq","type":"tool_result","content":"     1→/* eslint-disable @typescript-eslint/unbound-method */\n     2→import { Test, TestingModule } from '@nestjs/testing';\n     3→import { ConfigService } from '@nestjs/config';\n     4→import { PhaseHooksService, PhaseTask } from './phase-hooks.service';\n     5→import { GitHubService } from '../github';\n     6→import { CompoundService } from './compound.service';\n     7→import { EventService } from '../event/event.service';\n     8→import { ReleaseService } from '../release/release.service';\n     9→\n    10→describe('PhaseHooksService', () => {\n    11→  let module: TestingModule;\n    12→  let service: PhaseHooksService;\n    13→  let github: jest.Mocked<GitHubService>;\n    14→  let compound: jest.Mocked<CompoundService>;\n    15→  let releaseService: jest.Mocked<ReleaseService>;\n    16→\n    17→  const baseTask: PhaseTask = {\n    18→    issue: 42,\n    19→    repo: 'org/repo',\n    20→    directive: 'auto',\n    21→  };\n    22→\n    23→  beforeEach(async () => {\n    24→    module = await Test.createTestingModule({\n    25→      providers: [\n    26→        PhaseHooksService,\n    27→        {\n    28→          provide: GitHubService,\n    29→          useValue: {\n    30→            addLabel: jest.fn().mockResolvedValue(undefined),\n    31→            removeLabel: jest.fn().mockResolvedValue(undefined),\n    32→            postComment: jest.fn().mockResolvedValue(undefined),\n    33→            editComment: jest.fn().mockResolvedValue(undefined),\n    34→            mergePR: jest.fn().mockResolvedValue(undefined),\n    35→            closeIssue: jest.fn().mockResolvedValue(undefined),\n    36→            getCheckStatus: jest\n    37→              .fn()\n    38→              .mockResolvedValue({ passing: true, noChecks: false }),\n    39→            getReviewStatus: jest.fn().mockResolvedValue({ approved: true }),\n    40→            setProjectItemStatus: jest.fn().mockResolvedValue(undefined),\n    41→            findCommentByPrefix: jest.fn().mockResolvedValue(null),\n    42→          },\n    43→        },\n    44→        {\n    45→          provide: ConfigService,\n    46→          useValue: {\n    47→            get: jest.fn(),\n    48→          },\n    49→        },\n    50→        {\n    51→          provide: CompoundService,\n    52→          useValue: {\n    53→            writeLearnings: jest.fn().mockResolvedValue(undefined),\n    54→          },\n    55→        },\n    56→        {\n    57→          provide: EventService,\n    58→          useValue: {\n    59→            record: jest.fn().mockResolvedValue(undefined),\n    60→            countByType: jest.fn().mockResolvedValue(0),\n    61→          },\n    62→        },\n    63→        {\n    64→          provide: ReleaseService,\n    65→          useValue: {\n    66→            findActiveForTask: jest.fn().mockResolvedValue(null),\n    67→          },\n    68→        },\n    69→      ],\n    70→    }).compile();\n    71→\n    72→    service = module.get<PhaseHooksService>(PhaseHooksService);\n    73→    github = module.get(GitHubService);\n    74→    compound = module.get(CompoundService);\n    75→    releaseService = module.get(ReleaseService);\n    76→  });\n    77→\n    78→  it('should be defined', () => {\n    79→    expect(service).toBeDefined();\n    80→  });\n    81→\n    82→  describe('refine phase', () => {\n    83→    it('removes in-refinement label and adds refined label', async () => {\n    84→      await service.onPhaseComplete(baseTask, 'refine');\n    85→\n    86→      expect(github.removeLabel).toHaveBeenCalledWith(\n    87→        'org',\n    88→        'repo',\n    89→        42,\n    90→        'in-refinement',\n    91→      );\n    92→      expect(github.addLabel).toHaveBeenCalledWith(\n    93→        'org',\n    94→        'repo',\n    95→        42,\n    96→        'refined',\n    97→      );\n    98→    });\n    99→\n   100→    it('does not throw when label removal fails', async () => {\n   101→      github.removeLabel.mockRejectedValueOnce(new Error('Not Found'));\n   102→\n   103→      await expect(\n   104→        service.onPhaseComplete(baseTask, 'refine'),\n   105→      ).resolves.toBeUndefined();\n   106→\n   107→      expect(github.addLabel).toHaveBeenCalledWith(\n   108→        'org',\n   109→        'repo',\n   110→        42,\n   111→        'refined',\n   112→      );\n   113→    });\n   114→\n   115→    it('moves issue to AgentQueue on project board when PROJECT_ID is set', async () => {\n   116→      const config = module.get(ConfigService);\n   117→      (config.get as jest.Mock).mockReturnValue('PVT_test123');\n   118→\n   119→      await service.onPhaseComplete(baseTask, 'refine');\n   120→\n   121→      expect(github.setProjectItemStatus).toHaveBeenCalledWith(\n   122→        'PVT_test123',\n   123→        'org',\n   124→        'repo',\n   125→        42,\n   126→        'AgentQueue',\n   127→      );\n   128→    });\n   129→\n   130→    it('does not throw when project board update fails', async () => {\n   131→      const config = module.get(ConfigService);\n   132→      (config.get as jest.Mock).mockReturnValue('PVT_test123');\n   133→      github.setProjectItemStatus.mockRejectedValueOnce(\n   134→        new Error('GraphQL error'),\n   135→      );\n   136→\n   137→      await expect(\n   138→        service.onPhaseComplete(baseTask, 'refine'),\n   139→      ).resolves.toBeUndefined();\n   140→    });\n   141→\n   142→    it('skips project board update when PROJECT_ID is not set', async () => {\n   143→      const config = module.get(ConfigService);\n   144→      (config.get as jest.Mock).mockReturnValue(undefined);\n   145→\n   146→      await service.onPhaseComplete(baseTask, 'refine');\n   147→\n   148→      expect(github.setProjectItemStatus).not.toHaveBeenCalled();\n   149→    });\n   150→  });\n   151→\n   152→  describe('plan phase', () => {\n   153→    it('posts a plan complete comment when no active release', async () => {\n   154→      await service.onPhaseComplete(baseTask, 'plan');\n   155→\n   156→      expect(github.postComment).toHaveBeenCalledWith(\n   157→        'org',\n   158→        'repo',\n   159→        42,\n   160→        '**Plan phase complete.** Technical plan is ready.',\n   161→      );\n   162→    });\n   163→\n   164→    it('posts simple comment when taskId is undefined', async () => {\n   165→      const task: PhaseTask = { ...baseTask, taskId: undefined };\n   166→\n   167→      await service.onPhaseComplete(task, 'plan');\n   168→\n   169→      expect(releaseService.findActiveForTask).not.toHaveBeenCalled();\n   170→      expect(github.postComment).toHaveBeenCalledWith(\n   171→        'org',\n   172→        'repo',\n   173→        42,\n   174→        '**Plan phase complete.** Technical plan is ready.',\n   175→      );\n   176→    });\n   177→\n   178→    it('edits plan comment with release context when release is active and plan comment exists', async () => {\n   179→      const task: PhaseTask = { ...baseTask, taskId: 10 };\n   180→      (releaseService.findActiveForTask as jest.Mock).mockResolvedValueOnce({\n   181→        releaseId: 1,\n   182→        key: 'v2.0',\n   183→        title: 'Version 2.0',\n   184→        status: 'active',\n   185→        directive: 'auto',\n   186→        rcBranch: 'rc/v2.0',\n   187→        baseBranch: 'master',\n   188→      });\n   189→      (github.findCommentByPrefix as jest.Mock).mockResolvedValueOnce({\n   190→        id: 555,\n   191→        body: '## Agent Plan\\n\\nSome plan content.',\n   192→      });\n   193→\n   194→      await service.onPhaseComplete(task, 'plan');\n   195→\n   196→      expect(releaseService.findActiveForTask).toHaveBeenCalledWith(10);\n   197→      expect(github.findCommentByPrefix).toHaveBeenCalledWith(\n   198→        'org',\n   199→        'repo',\n   200→        42,\n   201→        '## Agent Plan',\n   202→      );\n   203→      expect(github.editComment).toHaveBeenCalledWith(\n   204→        'org',\n   205→        'repo',\n   206→        555,\n   207→        expect.stringContaining('## Release Context'),\n   208→      );\n   209→      expect(github.editComment).toHaveBeenCalledWith(\n   210→        'org',\n   211→        'repo',\n   212→        555,\n   213→        expect.stringContaining('v2.0'),\n   214→      );\n   215→      expect(github.postComment).not.toHaveBeenCalled();\n   216→    });\n   217→\n   218→    it('posts standalone release context comment when plan comment is not found', async () => {\n   219→      const task: PhaseTask = { ...baseTask, taskId: 10 };\n   220→      (releaseService.findActiveForTask as jest.Mock).mockResolvedValueOnce({\n   221→        releaseId: 1,\n   222→        key: 'v2.0',\n   223→        title: 'Version 2.0',\n   224→        status: 'active',\n   225→        directive: 'auto',\n   226→        rcBranch: 'rc/v2.0',\n   227→        baseBranch: 'master',\n   228→      });\n   229→      // findCommentByPrefix already returns null by default\n   230→\n   231→      await service.onPhaseComplete(task, 'plan');\n   232→\n   233→      expect(github.editComment).not.toHaveBeenCalled();\n   234→      expect(github.postComment).toHaveBeenCalledWith(\n   235→        'org',\n   236→        'repo',\n   237→        42,\n   238→        expect.stringContaining('## Release Context'),\n   239→      );\n   240→    });\n   241→\n   242→    it('falls back to simple comment when releaseService throws', async () => {\n   243→      const task: PhaseTask = { ...baseTask, taskId: 10 };\n   244→      (releaseService.findActiveForTask as jest.Mock).mockRejectedValueOnce(\n   245→        new Error('DB error'),\n   246→      );\n   247→\n   248→      await service.onPhaseComplete(task, 'plan');\n   249→\n   250→      expect(github.postComment).toHaveBeenCalledWith(\n   251→        'org',\n   252→        'repo',\n   253→        42,\n   254→        '**Plan phase complete.** Technical plan is ready.',\n   255→      );\n   256→    });\n   257→  });\n   258→\n   259→  describe('deliver phase', () => {\n   260→    it('adds in-review label and posts PR comment when PR exists', async () => {\n   261→      const task: PhaseTask = { ...baseTask, prNumber: 99 };\n   262→      await service.onPhaseComplete(task, 'deliver');\n   263→\n   264→      expect(github.addLabel).toHaveBeenCalledWith(\n   265→        'org',\n   266→        'repo',\n   267→        42,\n   268→        'in-review',\n   269→      );\n   270→      expect(github.postComment).toHaveBeenCalledWith(\n   271→        'org',\n   272→        'repo',\n   273→        42,\n   274→        '🚀 PR created: https://github.com/org/repo/pull/99',\n   275→      );\n   276→    });\n   277→\n   278→    it('does not post comment or add label when no PR exists', async () => {\n   279→      await expect(\n   280→        service.onPhaseComplete(baseTask, 'deliver'),\n   281→      ).resolves.toBeUndefined();\n   282→\n   283→      expect(github.addLabel).not.toHaveBeenCalled();\n   284→      expect(github.postComment).not.toHaveBeenCalled();\n   285→    });\n   286→  });\n   287→\n   288→  describe('review phase', () => {\n   289→    it('merges PR when auto directive and checks pass and review approved', async () => {\n   290→      const task: PhaseTask = {\n   291→        ...baseTask,\n   292→        prNumber: 10,\n   293→        prBranch: 'feat/test',\n   294→      };\n   295→\n   296→      await service.onPhaseComplete(task, 'review');\n   297→\n   298→      expect(github.getCheckStatus).toHaveBeenCalledWith(\n   299→        'org',\n   300→        'repo',\n   301→        'feat/test',\n   302→      );\n   303→      expect(github.getReviewStatus).toHaveBeenCalledWith('org', 'repo', 10);\n   304→      expect(github.mergePR).toHaveBeenCalledWith('org', 'repo', 10);\n   305→      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n   306→    });\n   307→\n   308→    it('does not merge PR when directive is careful', async () => {\n   309→      const task: PhaseTask = {\n   310→        ...baseTask,\n   311→        directive: 'careful',\n   312→        prNumber: 10,\n   313→        prBranch: 'feat/test',\n   314→      };\n   315→\n   316→      await service.onPhaseComplete(task, 'review');\n   317→\n   318→      expect(github.mergePR).not.toHaveBeenCalled();\n   319→      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n   320→    });\n   321→\n   322→    it('does not merge PR when checks are not passing', async () => {\n   323→      github.getCheckStatus.mockResolvedValueOnce({\n   324→        passing: false,\n   325→        noChecks: false,\n   326→      });\n   327→\n   328→      const task: PhaseTask = {\n   329→        ...baseTask,\n   330→        prNumber: 10,\n   331→        prBranch: 'feat/test',\n   332→      };\n   333→\n   334→      await service.onPhaseComplete(task, 'review');\n   335→\n   336→      expect(github.mergePR).not.toHaveBeenCalled();\n   337→      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n   338→    });\n   339→\n   340→    it('does not merge PR when review is not approved', async () => {\n   341→      github.getReviewStatus.mockResolvedValueOnce({ approved: false });\n   342→\n   343→      const task: PhaseTask = {\n   344→        ...baseTask,\n   345→        prNumber: 10,\n   346→        prBranch: 'feat/test',\n   347→      };\n   348→\n   349→      await service.onPhaseComplete(task, 'review');\n   350→\n   351→      expect(github.mergePR).not.toHaveBeenCalled();\n   352→      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n   353→    });\n   354→\n   355→    it('closes issue even when no PR exists', async () => {\n   356→      await service.onPhaseComplete(baseTask, 'review');\n   357→\n   358→      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n   359→    });\n   360→\n   361→    it('skips auto-merge and closes issue when prBranch is null', async () => {\n   362→      const task: PhaseTask = {\n   363→        ...baseTask,\n   364→        prNumber: 10,\n   365→        prBranch: undefined,\n   366→      };\n   367→\n   368→      await service.onPhaseComplete(task, 'review');\n   369→\n   370→      expect(github.getCheckStatus).not.toHaveBeenCalled();\n   371→      expect(github.mergePR).not.toHaveBeenCalled();\n   372→      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n   373→    });\n   374→  });\n   375→\n   376→  describe('compound phase', () => {\n   377→    it('calls writeLearnings and posts completion comment', async () => {\n   378→      await service.onPhaseComplete(baseTask, 'compound');\n   379→\n   380→      expect(compound.writeLearnings).toHaveBeenCalledWith(\n   381→        'org',\n   382→        'repo',\n   383→        baseTask,\n   384→      );\n   385→      expect(github.postComment).toHaveBeenCalledWith(\n   386→        'org',\n   387→        'repo',\n   388→        42,\n   389→        '**Task complete.** All phases finished successfully.',\n   390→      );\n   391→    });\n   392→\n   393→    it('still posts completion comment when writeLearnings fails', async () => {\n   394→      compound.writeLearnings.mockRejectedValueOnce(new Error('API error'));\n   395→\n   396→      await service.onPhaseComplete(baseTask, 'compound');\n   397→\n   398→      expect(github.postComment).toHaveBeenCalledWith(\n   399→        'org',\n   400→        'repo',\n   401→        42,\n   402→        '**Task complete.** All phases finished successfully.',\n   403→      );\n   404→    });\n   405→  });\n   406→\n   407→  describe('onPhaseSkip', () => {\n   408→    it('closes the issue when deliver phase is skipped', async () => {\n   409→      await service.onPhaseSkip(baseTask, 'deliver', 'no changes needed');\n   410→\n   411→      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n   412→    });\n   413→\n   414→    it('closes the issue with default reason when none provided', async () => {\n   415→      await service.onPhaseSkip(baseTask, 'deliver');\n   416→\n   417→      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n   418→    });\n   419→\n   420→    it('closes the issue when plan phase is skipped', async () => {\n   421→      await service.onPhaseSkip(baseTask, 'plan', 'no code changes needed');\n   422→\n   423→      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n   424→    });\n   425→\n   426→    it('does not close the issue for non-plan/deliver phases', async () => {\n   427→      await service.onPhaseSkip(baseTask, 'refine', 'nothing to refine');\n   428→\n   429→      expect(github.closeIssue).not.toHaveBeenCalled();\n   430→    });\n   431→\n   432→    it('catches and logs errors when closeIssue fails on skip', async () => {\n   433→      github.closeIssue.mockRejectedValueOnce(new Error('API error'));\n   434→\n   435→      await expect(\n   436→        service.onPhaseSkip(baseTask, 'deliver', 'no changes needed'),\n   437→      ).resolves.toBeUndefined();\n   438→    });\n   439→\n   440→    it('calls writeLearnings and posts completion comment when compound is skipped', async () => {\n   441→      await service.onPhaseSkip(baseTask, 'compound', 'no learnings');\n   442→\n   443→      expect(compound.writeLearnings).toHaveBeenCalledWith(\n   444→        'org',\n   445→        'repo',\n   446→        baseTask,\n   447→      );\n   448→      expect(github.postComment).toHaveBeenCalledWith(\n   449→        'org',\n   450→        'repo',\n   451→        42,\n   452→        '**Task complete.** All phases finished successfully.',\n   453→      );\n   454→    });\n   455→\n   456→    it('still posts completion comment when writeLearnings fails on compound skip', async () => {\n   457→      compound.writeLearnings.mockRejectedValueOnce(new Error('API error'));\n   458→\n   459→      await service.onPhaseSkip(baseTask, 'compound', 'no learnings');\n   460→\n   461→      expect(github.postComment).toHaveBeenCalledWith(\n   462→        'org',\n   463→        'repo',\n   464→        42,\n   465→        '**Task complete.** All phases finished successfully.',\n   466→      );\n   467→    });\n   468→  });\n   469→\n   470→  describe('onPhaseBlocked', () => {\n   471→    it('adds agent-blocked label and posts diagnostic comment for blocked category', async () => {\n   472→      await service.onPhaseBlocked(\n   473→        baseTask,\n   474→        'execute',\n   475→        'missing context',\n   476→        'blocked',\n   477→      );\n   478→\n   479→      expect(github.addLabel).toHaveBeenCalledWith(\n   480→        'org',\n   481→        'repo',\n   482→        42,\n   483→        'agent-blocked',\n   484→      );\n   485→      expect(github.postComment).toHaveBeenCalledWith(\n   486→        'org',\n   487→        'repo',\n   488→        42,\n   489→        expect.stringContaining('🚫 Phase persistently blocked'),\n   490→      );\n   491→      expect(github.postComment).toHaveBeenCalledWith(\n   492→        'org',\n   493→        'repo',\n   494→        42,\n   495→        expect.stringContaining('execute'),\n   496→      );\n   497→      expect(github.postComment).toHaveBeenCalledWith(\n   498→        'org',\n   499→        'repo',\n   500→        42,\n   501→        expect.stringContaining('missing context'),\n   502→      );\n   503→    });\n   504→\n   505→    it('adds agent-blocked label and posts comment for gated category on non-refine/review phase', async () => {\n   506→      await service.onPhaseBlocked(\n   507→        baseTask,\n   508→        'plan',\n   509→        'quality gate not passed',\n   510→        'gated',\n   511→      );\n   512→\n   513→      expect(github.addLabel).toHaveBeenCalledWith(\n   514→        'org',\n   515→        'repo',\n   516→        42,\n   517→        'agent-blocked',\n   518→      );\n   519→      expect(github.postComment).toHaveBeenCalledWith(\n   520→        'org',\n   521→        'repo',\n   522→        42,\n   523→        expect.stringContaining('🚫 Phase persistently blocked'),\n   524→      );\n   525→      expect(github.postComment).toHaveBeenCalledWith(\n   526→        'org',\n   527→        'repo',\n   528→        42,\n   529→        expect.stringContaining('quality gate not passed'),\n   530→      );\n   531→    });\n   532→\n   533→    it('suppresses label and comment for gated category on refine phase', async () => {\n   534→      await service.onPhaseBlocked(\n   535→        baseTask,\n   536→        'refine',\n   537→        'quality gate not passed',\n   538→        'gated',\n   539→      );\n   540→\n   541→      expect(github.addLabel).not.toHaveBeenCalled();\n   542→      expect(github.postComment).not.toHaveBeenCalled();\n   543→    });\n   544→\n   545→    it('suppresses label and comment for gated category on review phase', async () => {\n   546→      await service.onPhaseBlocked(\n   547→        baseTask,\n   548→        'review',\n   549→        'quality gate not passed',\n   550→        'gated',\n   551→      );\n   552→\n   553→      expect(github.addLabel).not.toHaveBeenCalled();\n   554→      expect(github.postComment).not.toHaveBeenCalled();\n   555→    });\n   556→\n   557→    it('uses stuck label text for stuck category', async () => {\n   558→      await service.onPhaseBlocked(\n   559→        baseTask,\n   560→        'plan',\n   561→        'no progress for 30min',\n   562→        'stuck',\n   563→      );\n   564→\n   565→      expect(github.postComment).toHaveBeenCalledWith(\n   566→        'org',\n   567→        'repo',\n   568→        42,\n   569→        expect.stringContaining('🔴 Task stuck'),\n   570→      );\n   571→    });\n   572→\n   573→    it('uses timeout label text for timeout category', async () => {\n   574→      await service.onPhaseBlocked(baseTask, 'refine', 'timed out', 'timeout');\n   575→\n   576→      expect(github.postComment).toHaveBeenCalledWith(\n   577→        'org',\n   578→        'repo',\n   579→        42,\n   580→        expect.stringContaining('⏱️ Phase timed out'),\n   581→      );\n   582→    });\n   583→\n   584→    it('does not throw when addLabel fails', async () => {\n   585→      github.addLabel.mockRejectedValueOnce(new Error('API error'));\n   586→\n   587→      await expect(\n   588→        service.onPhaseBlocked(baseTask, 'execute', 'blocked', 'blocked'),\n   589→      ).resolves.toBeUndefined();\n   590→\n   591→      expect(github.postComment).toHaveBeenCalled();\n   592→    });\n   593→\n   594→    it('does not throw when postComment fails', async () => {\n   595→      github.postComment.mockRejectedValueOnce(new Error('API error'));\n   596→\n   597→      await expect(\n   598→        service.onPhaseBlocked(baseTask, 'execute', 'blocked', 'blocked'),\n   599→      ).resolves.toBeUndefined();\n   600→    });\n   601→  });\n   602→\n   603→  describe('unknown phase', () => {\n   604→    it('does not throw for unknown phase', async () => {\n   605→      await expect(\n   606→        service.onPhaseComplete(baseTask, 'unknown-phase'),\n   607→      ).resolves.toBeUndefined();\n   608→    });\n   609→  });\n   610→\n   611→  describe('onPhaseStart', () => {\n   612→    it('adds in-refinement label when refine phase starts', async () => {\n   613→      await service.onPhaseStart(baseTask, 'refine');\n   614→\n   615→      expect(github.addLabel).toHaveBeenCalledWith(\n   616→        'org',\n   617→        'repo',\n   618→        42,\n   619→        'in-refinement',\n   620→      );\n   621→    });\n   622→\n   623→    it('does not call addLabel for non-refine phases', async () => {\n   624→      await service.onPhaseStart(baseTask, 'plan');\n   625→      await service.onPhaseStart(baseTask, 'deliver');\n   626→      await service.onPhaseStart(baseTask, 'review');\n   627→\n   628→      expect(github.addLabel).not.toHaveBeenCalled();\n   629→    });\n   630→\n   631→    it('catches addLabel failure and logs warning without throwing', async () => {\n   632→      github.addLabel.mockRejectedValueOnce(new Error('API error'));\n   633→\n   634→      await expect(\n   635→        service.onPhaseStart(baseTask, 'refine'),\n   636→      ).resolves.toBeUndefined();\n   637→    });\n   638→  });\n   639→\n   640→  describe('BullMQ retry idempotency', () => {\n   641→    let eventService: jest.Mocked<EventService>;\n   642→\n   643→    beforeEach(() => {\n   644→      eventService = module.get(EventService);\n   645→    });\n   646→\n   647→    describe('onPhaseBlocked() dedup', () => {\n   648→      it('posts blocked comment on first call and records dedup event', async () => {\n   649→        const task = { ...baseTask, taskId: 10 };\n   650→        (eventService.countByType as jest.Mock).mockResolvedValueOnce(0);\n   651→\n   652→        await service.onPhaseBlocked(task, 'plan', 'lint failed', 'blocked');\n   653→\n   654→        expect(eventService.record).toHaveBeenCalledWith(\n   655→          10,\n   656→          'phase_blocked_comment_posted:plan',\n   657→          'phase-hooks',\n   658→          { phase: 'plan', category: 'blocked' },\n   659→        );\n   660→        expect(github.addLabel).toHaveBeenCalledWith(\n   661→          'org',\n   662→          'repo',\n   663→          42,\n   664→          'agent-blocked',\n   665→        );\n   666→        expect(github.postComment).toHaveBeenCalledWith(\n   667→          'org',\n   668→          'repo',\n   669→          42,\n   670→          expect.stringContaining('lint failed'),\n   671→        );\n   672→      });\n   673→\n   674→      it('skips duplicate blocked comment when dedup event already recorded', async () => {\n   675→        const task = { ...baseTask, taskId: 10 };\n   676→        (eventService.countByType as jest.Mock).mockResolvedValueOnce(1); // already posted\n   677→\n   678→        await service.onPhaseBlocked(task, 'plan', 'lint failed', 'blocked');\n   679→\n   680→        expect(github.addLabel).not.toHaveBeenCalled();\n   681→        expect(github.postComment).not.toHaveBeenCalled();\n   682→      });\n   683→\n   684→      it('checks dedup event key scoped to the phase', async () => {\n   685→        const task = { ...baseTask, taskId: 10 };\n   686→        (eventService.countByType as jest.Mock).mockResolvedValueOnce(0);\n   687→\n   688→        await service.onPhaseBlocked(task, 'deliver', 'push failed', 'blocked');\n   689→\n   690→        expect(eventService.countByType).toHaveBeenCalledWith(\n   691→          10,\n   692→          'phase_blocked_comment_posted:deliver',\n   693→        );\n   694→      });\n   695→\n   696→      it('proceeds without dedup guard when taskId is undefined', async () => {\n   697→        // Tasks without a taskId get no event-based dedup — falls back to always posting\n   698→        await service.onPhaseBlocked(baseTask, 'plan', 'reason', 'blocked');\n   699→\n   700→        expect(eventService.countByType).not.toHaveBeenCalled();\n   701→        expect(github.postComment).toHaveBeenCalled();\n   702→      });\n   703→\n   704→      it('proceeds to post if countByType rejects (fail-open)', async () => {\n   705→        const task = { ...baseTask, taskId: 10 };\n   706→        (eventService.countByType as jest.Mock).mockRejectedValueOnce(\n   707→          new Error('DB error'),\n   708→        );\n   709→\n   710→        await service.onPhaseBlocked(task, 'plan', 'reason', 'blocked');\n   711→\n   712→        // countByType rejected → treated as 0 → proceeds normally\n   713→        expect(github.postComment).toHaveBeenCalled();\n   714→      });\n   715→    });\n   716→\n   717→    describe('onCompoundComplete() dedup', () => {\n   718→      it('posts completion comment on first call and records dedup event', async () => {\n   719→        const task = { ...baseTask, taskId: 20 };\n   720→        (eventService.countByType as jest.Mock).mockResolvedValueOnce(0);\n   721→\n   722→        await service.onPhaseComplete(task, 'compound');\n   723→\n   724→        expect(eventService.record).toHaveBeenCalledWith(\n   725→          20,\n   726→          'compound_complete_comment_posted',\n   727→          'phase-hooks',\n   728→          {},\n   729→        );\n   730→        expect(github.postComment).toHaveBeenCalledWith(\n   731→          'org',\n   732→          'repo',\n   733→          42,\n   734→          '**Task complete.** All phases finished successfully.',\n   735→        );\n   736→      });\n   737→\n   738→      it('skips duplicate completion comment when dedup event already recorded', async () => {\n   739→        const task = { ...baseTask, taskId: 20 };\n   740→        (eventService.countByType as jest.Mock).mockResolvedValueOnce(1); // already posted\n   741→\n   742→        await service.onPhaseComplete(task, 'compound');\n   743→\n   744→        expect(compound.writeLearnings).not.toHaveBeenCalled();\n   745→        expect(github.postComment).not.toHaveBeenCalled();\n   746→      });\n   747→\n   748→      it('still posts when taskId is undefined (no dedup guard)', async () => {\n   749→        await service.onPhaseComplete(baseTask, 'compound');\n   750→\n   751→        expect(eventService.countByType).not.toHaveBeenCalledWith(\n   752→          expect.anything(),\n   753→          'compound_complete_comment_posted',\n   754→        );\n   755→        expect(github.postComment).toHaveBeenCalledWith(\n   756→          'org',\n   757→          'repo',\n   758→          42,\n   759→          '**Task complete.** All phases finished successfully.',\n   760→        );\n   761→      });\n   762→    });\n   763→  });\n   764→});\n   765→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"9813c6dd-ade0-4131-8644-b021d2f64db2","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/hooks/phase-hooks.service.spec.ts","content":"/* eslint-disable @typescript-eslint/unbound-method */\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { ConfigService } from '@nestjs/config';\nimport { PhaseHooksService, PhaseTask } from './phase-hooks.service';\nimport { GitHubService } from '../github';\nimport { CompoundService } from './compound.service';\nimport { EventService } from '../event/event.service';\nimport { ReleaseService } from '../release/release.service';\n\ndescribe('PhaseHooksService', () => {\n  let module: TestingModule;\n  let service: PhaseHooksService;\n  let github: jest.Mocked<GitHubService>;\n  let compound: jest.Mocked<CompoundService>;\n  let releaseService: jest.Mocked<ReleaseService>;\n\n  const baseTask: PhaseTask = {\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n  };\n\n  beforeEach(async () => {\n    module = await Test.createTestingModule({\n      providers: [\n        PhaseHooksService,\n        {\n          provide: GitHubService,\n          useValue: {\n            addLabel: jest.fn().mockResolvedValue(undefined),\n            removeLabel: jest.fn().mockResolvedValue(undefined),\n            postComment: jest.fn().mockResolvedValue(undefined),\n            editComment: jest.fn().mockResolvedValue(undefined),\n            mergePR: jest.fn().mockResolvedValue(undefined),\n            closeIssue: jest.fn().mockResolvedValue(undefined),\n            getCheckStatus: jest\n              .fn()\n              .mockResolvedValue({ passing: true, noChecks: false }),\n            getReviewStatus: jest.fn().mockResolvedValue({ approved: true }),\n            setProjectItemStatus: jest.fn().mockResolvedValue(undefined),\n            findCommentByPrefix: jest.fn().mockResolvedValue(null),\n          },\n        },\n        {\n          provide: ConfigService,\n          useValue: {\n            get: jest.fn(),\n          },\n        },\n        {\n          provide: CompoundService,\n          useValue: {\n            writeLearnings: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get<PhaseHooksService>(PhaseHooksService);\n    github = module.get(GitHubService);\n    compound = module.get(CompoundService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  describe('refine phase', () => {\n    it('removes in-refinement label and adds refined label', async () => {\n      await service.onPhaseComplete(baseTask, 'refine');\n\n      expect(github.removeLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'in-refinement',\n      );\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'refined',\n      );\n    });\n\n    it('does not throw when label removal fails', async () => {\n      github.removeLabel.mockRejectedValueOnce(new Error('Not Found'));\n\n      await expect(\n        service.onPhaseComplete(baseTask, 'refine'),\n      ).resolves.toBeUndefined();\n\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'refined',\n      );\n    });\n\n    it('moves issue to AgentQueue on project board when PROJECT_ID is set', async () => {\n      const config = module.get(ConfigService);\n      (config.get as jest.Mock).mockReturnValue('PVT_test123');\n\n      await service.onPhaseComplete(baseTask, 'refine');\n\n      expect(github.setProjectItemStatus).toHaveBeenCalledWith(\n        'PVT_test123',\n        'org',\n        'repo',\n        42,\n        'AgentQueue',\n      );\n    });\n\n    it('does not throw when project board update fails', async () => {\n      const config = module.get(ConfigService);\n      (config.get as jest.Mock).mockReturnValue('PVT_test123');\n      github.setProjectItemStatus.mockRejectedValueOnce(\n        new Error('GraphQL error'),\n      );\n\n      await expect(\n        service.onPhaseComplete(baseTask, 'refine'),\n      ).resolves.toBeUndefined();\n    });\n\n    it('skips project board update when PROJECT_ID is not set', async () => {\n      const config = module.get(ConfigService);\n      (config.get as jest.Mock).mockReturnValue(undefined);\n\n      await service.onPhaseComplete(baseTask, 'refine');\n\n      expect(github.setProjectItemStatus).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('plan phase', () => {\n    it('posts a plan complete comment when no active release', async () => {\n      await service.onPhaseComplete(baseTask, 'plan');\n\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Plan phase complete.** Technical plan is ready.',\n      );\n    });\n\n    it('posts simple comment when taskId is undefined', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: undefined };\n\n      await service.onPhaseComplete(task, 'plan');\n\n      expect(releaseService.findActiveForTask).not.toHaveBeenCalled();\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Plan phase complete.** Technical plan is ready.',\n      );\n    });\n\n    it('edits plan comment with release context when release is active and plan comment exists', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: 10 };\n      (releaseService.findActiveForTask as jest.Mock).mockResolvedValueOnce({\n        releaseId: 1,\n        key: 'v2.0',\n        title: 'Version 2.0',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v2.0',\n        baseBranch: 'master',\n      });\n      (github.findCommentByPrefix as jest.Mock).mockResolvedValueOnce({\n        id: 555,\n        body: '## Agent Plan\\n\\nSome plan content.',\n      });\n\n      await service.onPhaseComplete(task, 'plan');\n\n      expect(releaseService.findActiveForTask).toHaveBeenCalledWith(10);\n      expect(github.findCommentByPrefix).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '## Agent Plan',\n      );\n      expect(github.editComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        555,\n        expect.stringContaining('## Release Context'),\n      );\n      expect(github.editComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        555,\n        expect.stringContaining('v2.0'),\n      );\n      expect(github.postComment).not.toHaveBeenCalled();\n    });\n\n    it('posts standalone release context comment when plan comment is not found', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: 10 };\n      (releaseService.findActiveForTask as jest.Mock).mockResolvedValueOnce({\n        releaseId: 1,\n        key: 'v2.0',\n        title: 'Version 2.0',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v2.0',\n        baseBranch: 'master',\n      });\n      // findCommentByPrefix already returns null by default\n\n      await service.onPhaseComplete(task, 'plan');\n\n      expect(github.editComment).not.toHaveBeenCalled();\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('## Release Context'),\n      );\n    });\n\n    it('falls back to simple comment when releaseService throws', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: 10 };\n      (releaseService.findActiveForTask as jest.Mock).mockRejectedValueOnce(\n        new Error('DB error'),\n      );\n\n      await service.onPhaseComplete(task, 'plan');\n\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Plan phase complete.** Technical plan is ready.',\n      );\n    });\n  });\n\n  describe('deliver phase', () => {\n    it('adds in-review label and posts PR comment when PR exists', async () => {\n      const task: PhaseTask = { ...baseTask, prNumber: 99 };\n      await service.onPhaseComplete(task, 'deliver');\n\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'in-review',\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '🚀 PR created: https://github.com/org/repo/pull/99',\n      );\n    });\n\n    it('does not post comment or add label when no PR exists', async () => {\n      await expect(\n        service.onPhaseComplete(baseTask, 'deliver'),\n      ).resolves.toBeUndefined();\n\n      expect(github.addLabel).not.toHaveBeenCalled();\n      expect(github.postComment).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('review phase', () => {\n    it('merges PR when auto directive and checks pass and review approved', async () => {\n      const task: PhaseTask = {\n        ...baseTask,\n        prNumber: 10,\n        prBranch: 'feat/test',\n      };\n\n      await service.onPhaseComplete(task, 'review');\n\n      expect(github.getCheckStatus).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        'feat/test',\n      );\n      expect(github.getReviewStatus).toHaveBeenCalledWith('org', 'repo', 10);\n      expect(github.mergePR).toHaveBeenCalledWith('org', 'repo', 10);\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('does not merge PR when directive is careful', async () => {\n      const task: PhaseTask = {\n        ...baseTask,\n        directive: 'careful',\n        prNumber: 10,\n        prBranch: 'feat/test',\n      };\n\n      await service.onPhaseComplete(task, 'review');\n\n      expect(github.mergePR).not.toHaveBeenCalled();\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('does not merge PR when checks are not passing', async () => {\n      github.getCheckStatus.mockResolvedValueOnce({\n        passing: false,\n        noChecks: false,\n      });\n\n      const task: PhaseTask = {\n        ...baseTask,\n        prNumber: 10,\n        prBranch: 'feat/test',\n      };\n\n      await service.onPhaseComplete(task, 'review');\n\n      expect(github.mergePR).not.toHaveBeenCalled();\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('does not merge PR when review is not approved', async () => {\n      github.getReviewStatus.mockResolvedValueOnce({ approved: false });\n\n      const task: PhaseTask = {\n        ...baseTask,\n        prNumber: 10,\n        prBranch: 'feat/test',\n      };\n\n      await service.onPhaseComplete(task, 'review');\n\n      expect(github.mergePR).not.toHaveBeenCalled();\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('closes issue even when no PR exists', async () => {\n      await service.onPhaseComplete(baseTask, 'review');\n\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('skips auto-merge and closes issue when prBranch is null', async () => {\n      const task: PhaseTask = {\n        ...baseTask,\n        prNumber: 10,\n        prBranch: undefined,\n      };\n\n      await service.onPhaseComplete(task, 'review');\n\n      expect(github.getCheckStatus).not.toHaveBeenCalled();\n      expect(github.mergePR).not.toHaveBeenCalled();\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n  });\n\n  describe('compound phase', () => {\n    it('calls writeLearnings and posts completion comment', async () => {\n      await service.onPhaseComplete(baseTask, 'compound');\n\n      expect(compound.writeLearnings).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        baseTask,\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Task complete.** All phases finished successfully.',\n      );\n    });\n\n    it('still posts completion comment when writeLearnings fails', async () => {\n      compound.writeLearnings.mockRejectedValueOnce(new Error('API error'));\n\n      await service.onPhaseComplete(baseTask, 'compound');\n\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Task complete.** All phases finished successfully.',\n      );\n    });\n  });\n\n  describe('onPhaseSkip', () => {\n    it('closes the issue when deliver phase is skipped', async () => {\n      await service.onPhaseSkip(baseTask, 'deliver', 'no changes needed');\n\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('closes the issue with default reason when none provided', async () => {\n      await service.onPhaseSkip(baseTask, 'deliver');\n\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('closes the issue when plan phase is skipped', async () => {\n      await service.onPhaseSkip(baseTask, 'plan', 'no code changes needed');\n\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('does not close the issue for non-plan/deliver phases', async () => {\n      await service.onPhaseSkip(baseTask, 'refine', 'nothing to refine');\n\n      expect(github.closeIssue).not.toHaveBeenCalled();\n    });\n\n    it('catches and logs errors when closeIssue fails on skip', async () => {\n      github.closeIssue.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseSkip(baseTask, 'deliver', 'no changes needed'),\n      ).resolves.toBeUndefined();\n    });\n\n    it('calls writeLearnings and posts completion comment when compound is skipped', async () => {\n      await service.onPhaseSkip(baseTask, 'compound', 'no learnings');\n\n      expect(compound.writeLearnings).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        baseTask,\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Task complete.** All phases finished successfully.',\n      );\n    });\n\n    it('still posts completion comment when writeLearnings fails on compound skip', async () => {\n      compound.writeLearnings.mockRejectedValueOnce(new Error('API error'));\n\n      await service.onPhaseSkip(baseTask, 'compound', 'no learnings');\n\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Task complete.** All phases finished successfully.',\n      );\n    });\n  });\n\n  describe('onPhaseBlocked', () => {\n    it('adds agent-blocked label and posts diagnostic comment for blocked category', async () => {\n      await service.onPhaseBlocked(\n        baseTask,\n        'execute',\n        'missing context',\n        'blocked',\n      );\n\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'agent-blocked',\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('🚫 Phase persistently blocked'),\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('execute'),\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('missing context'),\n      );\n    });\n\n    it('adds agent-blocked label and posts comment for gated category on non-refine/review phase', async () => {\n      await service.onPhaseBlocked(\n        baseTask,\n        'plan',\n        'quality gate not passed',\n        'gated',\n      );\n\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'agent-blocked',\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('🚫 Phase persistently blocked'),\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('quality gate not passed'),\n      );\n    });\n\n    it('suppresses label and comment for gated category on refine phase', async () => {\n      await service.onPhaseBlocked(\n        baseTask,\n        'refine',\n        'quality gate not passed',\n        'gated',\n      );\n\n      expect(github.addLabel).not.toHaveBeenCalled();\n      expect(github.postComment).not.toHaveBeenCalled();\n    });\n\n    it('suppresses label and comment for gated category on review phase', async () => {\n      await service.onPhaseBlocked(\n        baseTask,\n        'review',\n        'quality gate not passed',\n        'gated',\n      );\n\n      expect(github.addLabel).not.toHaveBeenCalled();\n      expect(github.postComment).not.toHaveBeenCalled();\n    });\n\n    it('uses stuck label text for stuck category', async () => {\n      await service.onPhaseBlocked(\n        baseTask,\n        'plan',\n        'no progress for 30min',\n        'stuck',\n      );\n\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('🔴 Task stuck'),\n      );\n    });\n\n    it('uses timeout label text for timeout category', async () => {\n      await service.onPhaseBlocked(baseTask, 'refine', 'timed out', 'timeout');\n\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('⏱️ Phase timed out'),\n      );\n    });\n\n    it('does not throw when addLabel fails', async () => {\n      github.addLabel.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseBlocked(baseTask, 'execute', 'blocked', 'blocked'),\n      ).resolves.toBeUndefined();\n\n      expect(github.postComment).toHaveBeenCalled();\n    });\n\n    it('does not throw when postComment fails', async () => {\n      github.postComment.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseBlocked(baseTask, 'execute', 'blocked', 'blocked'),\n      ).resolves.toBeUndefined();\n    });\n  });\n\n  describe('unknown phase', () => {\n    it('does not throw for unknown phase', async () => {\n      await expect(\n        service.onPhaseComplete(baseTask, 'unknown-phase'),\n      ).resolves.toBeUndefined();\n    });\n  });\n\n  describe('onPhaseStart', () => {\n    it('adds in-refinement label when refine phase starts', async () => {\n      await service.onPhaseStart(baseTask, 'refine');\n\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'in-refinement',\n      );\n    });\n\n    it('does not call addLabel for non-refine phases', async () => {\n      await service.onPhaseStart(baseTask, 'plan');\n      await service.onPhaseStart(baseTask, 'deliver');\n      await service.onPhaseStart(baseTask, 'review');\n\n      expect(github.addLabel).not.toHaveBeenCalled();\n    });\n\n    it('catches addLabel failure and logs warning without throwing', async () => {\n      github.addLabel.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseStart(baseTask, 'refine'),\n      ).resolves.toBeUndefined();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    let eventService: jest.Mocked<EventService>;\n\n    beforeEach(() => {\n      eventService = module.get(EventService);\n    });\n\n    describe('onPhaseBlocked() dedup', () => {\n      it('posts blocked comment on first call and records dedup event', async () => {\n        const task = { ...baseTask, taskId: 10 };\n        (eventService.countByType as jest.Mock).mockResolvedValueOnce(0);\n\n        await service.onPhaseBlocked(task, 'plan', 'lint failed', 'blocked');\n\n        expect(eventService.record).toHaveBeenCalledWith(\n          10,\n          'phase_blocked_comment_posted:plan',\n          'phase-hooks',\n          { phase: 'plan', category: 'blocked' },\n        );\n        expect(github.addLabel).toHaveBeenCalledWith(\n          'org',\n          'repo',\n          42,\n          'agent-blocked',\n        );\n        expect(github.postComment).toHaveBeenCalledWith(\n          'org',\n          'repo',\n          42,\n          expect.stringContaining('lint failed'),\n        );\n      });\n\n      it('skips duplicate blocked comment when dedup event already recorded', async () => {\n        const task = { ...baseTask, taskId: 10 };\n        (eventService.countByType as jest.Mock).mockResolvedValueOnce(1); // already posted\n\n        await service.onPhaseBlocked(task, 'plan', 'lint failed', 'blocked');\n\n        expect(github.addLabel).not.toHaveBeenCalled();\n        expect(github.postComment).not.toHaveBeenCalled();\n      });\n\n      it('checks dedup event key scoped to the phase', async () => {\n        const task = { ...baseTask, taskId: 10 };\n        (eventService.countByType as jest.Mock).mockResolvedValueOnce(0);\n\n        await service.onPhaseBlocked(task, 'deliver', 'push failed', 'blocked');\n\n        expect(eventService.countByType).toHaveBeenCalledWith(\n          10,\n          'phase_blocked_comment_posted:deliver',\n        );\n      });\n\n      it('proceeds without dedup guard when taskId is undefined', async () => {\n        // Tasks without a taskId get no event-based dedup — falls back to always posting\n        await service.onPhaseBlocked(baseTask, 'plan', 'reason', 'blocked');\n\n        expect(eventService.countByType).not.toHaveBeenCalled();\n        expect(github.postComment).toHaveBeenCalled();\n      });\n\n      it('proceeds to post if countByType rejects (fail-open)', async () => {\n        const task = { ...baseTask, taskId: 10 };\n        (eventService.countByType as jest.Mock).mockRejectedValueOnce(\n          new Error('DB error'),\n        );\n\n        await service.onPhaseBlocked(task, 'plan', 'reason', 'blocked');\n\n        // countByType rejected → treated as 0 → proceeds normally\n        expect(github.postComment).toHaveBeenCalled();\n      });\n    });\n\n    describe('onCompoundComplete() dedup', () => {\n      it('posts completion comment on first call and records dedup event', async () => {\n        const task = { ...baseTask, taskId: 20 };\n        (eventService.countByType as jest.Mock).mockResolvedValueOnce(0);\n\n        await service.onPhaseComplete(task, 'compound');\n\n        expect(eventService.record).toHaveBeenCalledWith(\n          20,\n          'compound_complete_comment_posted',\n          'phase-hooks',\n          {},\n        );\n        expect(github.postComment).toHaveBeenCalledWith(\n          'org',\n          'repo',\n          42,\n          '**Task complete.** All phases finished successfully.',\n        );\n      });\n\n      it('skips duplicate completion comment when dedup event already recorded', async () => {\n        const task = { ...baseTask, taskId: 20 };\n        (eventService.countByType as jest.Mock).mockResolvedValueOnce(1); // already posted\n\n        await service.onPhaseComplete(task, 'compound');\n\n        expect(compound.writeLearnings).not.toHaveBeenCalled();\n        expect(github.postComment).not.toHaveBeenCalled();\n      });\n\n      it('still posts when taskId is undefined (no dedup guard)', async () => {\n        await service.onPhaseComplete(baseTask, 'compound');\n\n        expect(eventService.countByType).not.toHaveBeenCalledWith(\n          expect.anything(),\n          'compound_complete_comment_posted',\n        );\n        expect(github.postComment).toHaveBeenCalledWith(\n          'org',\n          'repo',\n          42,\n          '**Task complete.** All phases finished successfully.',\n        );\n      });\n    });\n  });\n});\n","numLines":765,"startLine":1,"totalLines":765}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_019s1QhNoKoMkdf9zsNmweLZ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TD7EoiZPvBQziR8AJ4w9Gd","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/hooks/compound.service.spec.ts"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":20260,"cache_read_input_tokens":31565,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":20260},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"15168719-caae-4e06-bea9-8b13b9a661b2"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TD7EoiZPvBQziR8AJ4w9Gd","type":"tool_result","content":"     1→/* eslint-disable @typescript-eslint/unbound-method */\n     2→import { Test, TestingModule } from '@nestjs/testing';\n     3→import { CompoundService } from './compound.service';\n     4→import { GitHubService } from '../github';\n     5→import { EventService } from '../event/event.service';\n     6→import type { PhaseTask } from './phase-hooks.service';\n     7→import type { EventEntity } from '../database';\n     8→\n     9→describe('CompoundService', () => {\n    10→  let service: CompoundService;\n    11→  let github: jest.Mocked<GitHubService>;\n    12→  let eventService: jest.Mocked<EventService>;\n    13→\n    14→  const baseTask: PhaseTask = {\n    15→    issue: 42,\n    16→    repo: 'org/repo',\n    17→    directive: 'auto',\n    18→    taskId: 1,\n    19→  };\n    20→\n    21→  beforeEach(async () => {\n    22→    const module: TestingModule = await Test.createTestingModule({\n    23→      providers: [\n    24→        CompoundService,\n    25→        {\n    26→          provide: GitHubService,\n    27→          useValue: {\n    28→            getFileContent: jest.fn().mockResolvedValue(null),\n    29→            createOrUpdateFile: jest.fn().mockResolvedValue(undefined),\n    30→          },\n    31→        },\n    32→        {\n    33→          provide: EventService,\n    34→          useValue: {\n    35→            findByTask: jest.fn().mockResolvedValue([]),\n    36→          },\n    37→        },\n    38→      ],\n    39→    }).compile();\n    40→\n    41→    service = module.get<CompoundService>(CompoundService);\n    42→    github = module.get(GitHubService);\n    43→    eventService = module.get(EventService);\n    44→  });\n    45→\n    46→  it('should be defined', () => {\n    47→    expect(service).toBeDefined();\n    48→  });\n    49→\n    50→  describe('writeLearnings', () => {\n    51→    it('skips when task has no taskId', async () => {\n    52→      const task: PhaseTask = { ...baseTask, taskId: undefined };\n    53→\n    54→      await service.writeLearnings('org', 'repo', task);\n    55→\n    56→      expect(github.createOrUpdateFile).not.toHaveBeenCalled();\n    57→    });\n    58→\n    59→    it('skips when no phase events exist', async () => {\n    60→      eventService.findByTask.mockResolvedValueOnce([]);\n    61→\n    62→      await service.writeLearnings('org', 'repo', baseTask);\n    63→\n    64→      expect(github.createOrUpdateFile).not.toHaveBeenCalled();\n    65→    });\n    66→\n    67→    it('creates AGENTS.md when file does not exist', async () => {\n    68→      eventService.findByTask.mockResolvedValueOnce([\n    69→        { type: 'refine_completed', taskId: 1 } as EventEntity,\n    70→        { type: 'plan_completed', taskId: 1 } as EventEntity,\n    71→        { type: 'execute_completed', taskId: 1 } as EventEntity,\n    72→      ]);\n    73→      github.getFileContent.mockResolvedValueOnce(null);\n    74→\n    75→      await service.writeLearnings('org', 'repo', baseTask);\n    76→\n    77→      expect(github.createOrUpdateFile).toHaveBeenCalledWith(\n    78→        'org',\n    79→        'repo',\n    80→        'AGENTS.md',\n    81→        expect.stringContaining('# Agent Learnings'),\n    82→        expect.stringContaining('issue #42'),\n    83→        undefined,\n    84→      );\n    85→    });\n    86→\n    87→    it('appends to existing AGENTS.md with sha', async () => {\n    88→      eventService.findByTask.mockResolvedValueOnce([\n    89→        { type: 'execute_completed', taskId: 1 } as EventEntity,\n    90→      ]);\n    91→      github.getFileContent.mockResolvedValueOnce({\n    92→        content: '# Agent Learnings\\n\\n## Previous entry\\n',\n    93→        sha: 'abc123',\n    94→      });\n    95→\n    96→      await service.writeLearnings('org', 'repo', baseTask);\n    97→\n    98→      expect(github.createOrUpdateFile).toHaveBeenCalledWith(\n    99→        'org',\n   100→        'repo',\n   101→        'AGENTS.md',\n   102→        expect.stringContaining('## Previous entry'),\n   103→        expect.stringContaining('issue #42'),\n   104→        'abc123',\n   105→      );\n   106→\n   107→      // Verify it appends, not overwrites\n   108→      const content = github.createOrUpdateFile.mock.calls[0][3];\n   109→      expect(content).toMatch(/^# Agent Learnings/);\n   110→      expect(content).toContain('## Previous entry');\n   111→      expect(content).toContain('## Issue #42');\n   112→    });\n   113→\n   114→    it('includes PR number in entry when present', async () => {\n   115→      const task: PhaseTask = { ...baseTask, prNumber: 99 };\n   116→      eventService.findByTask.mockResolvedValueOnce([\n   117→        { type: 'deliver_completed', taskId: 1 } as EventEntity,\n   118→      ]);\n   119→\n   120→      await service.writeLearnings('org', 'repo', task);\n   121→\n   122→      const content = github.createOrUpdateFile.mock.calls[0][3];\n   123→      expect(content).toContain('**PR:** #99');\n   124→    });\n   125→\n   126→    it('deduplicates phases keeping last occurrence', async () => {\n   127→      eventService.findByTask.mockResolvedValueOnce([\n   128→        { type: 'execute_failed', taskId: 1 } as EventEntity,\n   129→        { type: 'execute_completed', taskId: 1 } as EventEntity,\n   130→      ]);\n   131→\n   132→      const summary = await service.buildSummary(baseTask);\n   133→\n   134→      expect(summary).toBe('execute: completed');\n   135→    });\n   136→\n   137→    it('includes skipped phases in summary', async () => {\n   138→      eventService.findByTask.mockResolvedValueOnce([\n   139→        { type: 'refine_completed', taskId: 1 } as EventEntity,\n   140→        { type: 'review_skipped', taskId: 1 } as EventEntity,\n   141→      ]);\n   142→\n   143→      const summary = await service.buildSummary(baseTask);\n   144→\n   145→      expect(summary).toBe('refine: completed, review: skipped');\n   146→    });\n   147→  });\n   148→\n   149→  describe('buildSummary', () => {\n   150→    it('returns null when no taskId', async () => {\n   151→      const task: PhaseTask = { ...baseTask, taskId: undefined };\n   152→      const summary = await service.buildSummary(task);\n   153→      expect(summary).toBeNull();\n   154→    });\n   155→\n   156→    it('returns null when no phase events', async () => {\n   157→      eventService.findByTask.mockResolvedValueOnce([\n   158→        { type: 'claude_invocation_complete', taskId: 1 } as EventEntity,\n   159→      ]);\n   160→      const summary = await service.buildSummary(baseTask);\n   161→      expect(summary).toBeNull();\n   162→    });\n   163→\n   164→    it('returns summary string for completed phases', async () => {\n   165→      eventService.findByTask.mockResolvedValueOnce([\n   166→        { type: 'refine_completed', taskId: 1 } as EventEntity,\n   167→        { type: 'plan_completed', taskId: 1 } as EventEntity,\n   168→      ]);\n   169→      const summary = await service.buildSummary(baseTask);\n   170→      expect(summary).toBe('refine: completed, plan: completed');\n   171→    });\n   172→\n   173→    it('excludes compound_completed from summary', async () => {\n   174→      // compound_completed is recorded before the hook runs; must not appear\n   175→      // in the phase-trace written to AGENTS.md.\n   176→      eventService.findByTask.mockResolvedValueOnce([\n   177→        { type: 'refine_completed', taskId: 1 } as EventEntity,\n   178→        { type: 'deliver_completed', taskId: 1 } as EventEntity,\n   179→        { type: 'compound_completed', taskId: 1 } as EventEntity,\n   180→      ]);\n   181→      const summary = await service.buildSummary(baseTask);\n   182→      expect(summary).toBe('refine: completed, deliver: completed');\n   183→      expect(summary).not.toContain('compound');\n   184→    });\n   185→\n   186→    it('returns null when only compound events exist', async () => {\n   187→      // No earlier-phase events means nothing useful to summarise.\n   188→      eventService.findByTask.mockResolvedValueOnce([\n   189→        { type: 'compound_completed', taskId: 1 } as EventEntity,\n   190→      ]);\n   191→      const summary = await service.buildSummary(baseTask);\n   192→      expect(summary).toBeNull();\n   193→    });\n   194→  });\n   195→});\n   196→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"921b405b-4eac-49a2-a507-c8950848d1bf","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/hooks/compound.service.spec.ts","content":"/* eslint-disable @typescript-eslint/unbound-method */\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { CompoundService } from './compound.service';\nimport { GitHubService } from '../github';\nimport { EventService } from '../event/event.service';\nimport type { PhaseTask } from './phase-hooks.service';\nimport type { EventEntity } from '../database';\n\ndescribe('CompoundService', () => {\n  let service: CompoundService;\n  let github: jest.Mocked<GitHubService>;\n  let eventService: jest.Mocked<EventService>;\n\n  const baseTask: PhaseTask = {\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    taskId: 1,\n  };\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        CompoundService,\n        {\n          provide: GitHubService,\n          useValue: {\n            getFileContent: jest.fn().mockResolvedValue(null),\n            createOrUpdateFile: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            findByTask: jest.fn().mockResolvedValue([]),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get<CompoundService>(CompoundService);\n    github = module.get(GitHubService);\n    eventService = module.get(EventService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  describe('writeLearnings', () => {\n    it('skips when task has no taskId', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: undefined };\n\n      await service.writeLearnings('org', 'repo', task);\n\n      expect(github.createOrUpdateFile).not.toHaveBeenCalled();\n    });\n\n    it('skips when no phase events exist', async () => {\n      eventService.findByTask.mockResolvedValueOnce([]);\n\n      await service.writeLearnings('org', 'repo', baseTask);\n\n      expect(github.createOrUpdateFile).not.toHaveBeenCalled();\n    });\n\n    it('creates AGENTS.md when file does not exist', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'plan_completed', taskId: 1 } as EventEntity,\n        { type: 'execute_completed', taskId: 1 } as EventEntity,\n      ]);\n      github.getFileContent.mockResolvedValueOnce(null);\n\n      await service.writeLearnings('org', 'repo', baseTask);\n\n      expect(github.createOrUpdateFile).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        'AGENTS.md',\n        expect.stringContaining('# Agent Learnings'),\n        expect.stringContaining('issue #42'),\n        undefined,\n      );\n    });\n\n    it('appends to existing AGENTS.md with sha', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'execute_completed', taskId: 1 } as EventEntity,\n      ]);\n      github.getFileContent.mockResolvedValueOnce({\n        content: '# Agent Learnings\\n\\n## Previous entry\\n',\n        sha: 'abc123',\n      });\n\n      await service.writeLearnings('org', 'repo', baseTask);\n\n      expect(github.createOrUpdateFile).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        'AGENTS.md',\n        expect.stringContaining('## Previous entry'),\n        expect.stringContaining('issue #42'),\n        'abc123',\n      );\n\n      // Verify it appends, not overwrites\n      const content = github.createOrUpdateFile.mock.calls[0][3];\n      expect(content).toMatch(/^# Agent Learnings/);\n      expect(content).toContain('## Previous entry');\n      expect(content).toContain('## Issue #42');\n    });\n\n    it('includes PR number in entry when present', async () => {\n      const task: PhaseTask = { ...baseTask, prNumber: 99 };\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'deliver_completed', taskId: 1 } as EventEntity,\n      ]);\n\n      await service.writeLearnings('org', 'repo', task);\n\n      const content = github.createOrUpdateFile.mock.calls[0][3];\n      expect(content).toContain('**PR:** #99');\n    });\n\n    it('deduplicates phases keeping last occurrence', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'execute_failed', taskId: 1 } as EventEntity,\n        { type: 'execute_completed', taskId: 1 } as EventEntity,\n      ]);\n\n      const summary = await service.buildSummary(baseTask);\n\n      expect(summary).toBe('execute: completed');\n    });\n\n    it('includes skipped phases in summary', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'review_skipped', taskId: 1 } as EventEntity,\n      ]);\n\n      const summary = await service.buildSummary(baseTask);\n\n      expect(summary).toBe('refine: completed, review: skipped');\n    });\n  });\n\n  describe('buildSummary', () => {\n    it('returns null when no taskId', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: undefined };\n      const summary = await service.buildSummary(task);\n      expect(summary).toBeNull();\n    });\n\n    it('returns null when no phase events', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'claude_invocation_complete', taskId: 1 } as EventEntity,\n      ]);\n      const summary = await service.buildSummary(baseTask);\n      expect(summary).toBeNull();\n    });\n\n    it('returns summary string for completed phases', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'plan_completed', taskId: 1 } as EventEntity,\n      ]);\n      const summary = await service.buildSummary(baseTask);\n      expect(summary).toBe('refine: completed, plan: completed');\n    });\n\n    it('excludes compound_completed from summary', async () => {\n      // compound_completed is recorded before the hook runs; must not appear\n      // in the phase-trace written to AGENTS.md.\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'deliver_completed', taskId: 1 } as EventEntity,\n        { type: 'compound_completed', taskId: 1 } as EventEntity,\n      ]);\n      const summary = await service.buildSummary(baseTask);\n      expect(summary).toBe('refine: completed, deliver: completed');\n      expect(summary).not.toContain('compound');\n    });\n\n    it('returns null when only compound events exist', async () => {\n      // No earlier-phase events means nothing useful to summarise.\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'compound_completed', taskId: 1 } as EventEntity,\n      ]);\n      const summary = await service.buildSummary(baseTask);\n      expect(summary).toBeNull();\n    });\n  });\n});\n","numLines":196,"startLine":1,"totalLines":196}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_019s1QhNoKoMkdf9zsNmweLZ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JfzR9J8FsDz2cUdAYo18UU","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/watchdog/watchdog.service.spec.ts"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":20260,"cache_read_input_tokens":31565,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":20260},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"845b012d-370a-4b76-8818-c4b4dd51e7a5"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JfzR9J8FsDz2cUdAYo18UU","type":"tool_result","content":"     1→/* eslint-disable @typescript-eslint/unbound-method */\n     2→import { Test, TestingModule } from '@nestjs/testing';\n     3→import { WatchdogService } from './watchdog.service';\n     4→import { TaskStateService } from '../task-state/task-state.service';\n     5→import { EventService } from '../event/event.service';\n     6→import { PhaseHooksService } from '../hooks/phase-hooks.service';\n     7→import { EventBusService } from '../event-bus';\n     8→import { TaskEntity } from '../database/entities';\n     9→import { EventEntity } from '../database/entities';\n    10→\n    11→describe('WatchdogService', () => {\n    12→  let service: WatchdogService;\n    13→  let taskStateService: jest.Mocked<TaskStateService>;\n    14→  let eventService: jest.Mocked<EventService>;\n    15→  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n    16→  let phaseHooks: jest.Mocked<PhaseHooksService>;\n    17→  let originalStuck: string | undefined;\n    18→  let originalSlow: string | undefined;\n    19→\n    20→  beforeAll(() => {\n    21→    // Pin thresholds so tests are not affected by environment configuration.\n    22→    originalStuck = process.env.STUCK_THRESHOLD_MS;\n    23→    originalSlow = process.env.SLOW_THRESHOLD_MS;\n    24→    process.env.STUCK_THRESHOLD_MS = '1800000'; // 30 min\n    25→    process.env.SLOW_THRESHOLD_MS = '900000'; // 15 min\n    26→  });\n    27→\n    28→  afterAll(() => {\n    29→    if (originalStuck !== undefined) {\n    30→      process.env.STUCK_THRESHOLD_MS = originalStuck;\n    31→    } else {\n    32→      delete process.env.STUCK_THRESHOLD_MS;\n    33→    }\n    34→    if (originalSlow !== undefined) {\n    35→      process.env.SLOW_THRESHOLD_MS = originalSlow;\n    36→    } else {\n    37→      delete process.env.SLOW_THRESHOLD_MS;\n    38→    }\n    39→  });\n    40→\n    41→  // Pin thresholds so tests are independent of the runtime environment.\n    42→  // WatchdogService reads these from process.env at instantiation time.\n    43→  const ORIG_STUCK = process.env.STUCK_THRESHOLD_MS;\n    44→  const ORIG_SLOW = process.env.SLOW_THRESHOLD_MS;\n    45→\n    46→  beforeEach(async () => {\n    47→    process.env.STUCK_THRESHOLD_MS = '1800000'; // 30 min\n    48→    process.env.SLOW_THRESHOLD_MS = '900000'; // 15 min\n    49→\n    50→    const module: TestingModule = await Test.createTestingModule({\n    51→      providers: [\n    52→        WatchdogService,\n    53→        {\n    54→          provide: TaskStateService,\n    55→          useValue: {\n    56→            findByStatus: jest.fn().mockResolvedValue([]),\n    57→            findByStatuses: jest.fn().mockResolvedValue([]),\n    58→            save: jest\n    59→              .fn()\n    60→              .mockImplementation((t: TaskEntity) => Promise.resolve(t)),\n    61→          },\n    62→        },\n    63→        {\n    64→          provide: EventService,\n    65→          useValue: {\n    66→            record: jest.fn().mockResolvedValue({}),\n    67→            findByTask: jest.fn().mockResolvedValue([]),\n    68→            countByType: jest.fn().mockResolvedValue(0),\n    69→          },\n    70→        },\n    71→        {\n    72→          provide: EventBusService,\n    73→          useValue: {\n    74→            emit: jest.fn(),\n    75→            on: jest.fn(),\n    76→          },\n    77→        },\n    78→        {\n    79→          provide: PhaseHooksService,\n    80→          useValue: {\n    81→            onPhaseBlocked: jest.fn().mockResolvedValue(undefined),\n    82→          },\n    83→        },\n    84→      ],\n    85→    }).compile();\n    86→\n    87→    service = module.get<WatchdogService>(WatchdogService);\n    88→    taskStateService = module.get(TaskStateService);\n    89→    eventService = module.get(EventService);\n    90→    eventBus = module.get(EventBusService);\n    91→    phaseHooks = module.get(PhaseHooksService);\n    92→  });\n    93→\n    94→  afterEach(() => {\n    95→    if (ORIG_STUCK === undefined) {\n    96→      delete process.env.STUCK_THRESHOLD_MS;\n    97→    } else {\n    98→      process.env.STUCK_THRESHOLD_MS = ORIG_STUCK;\n    99→    }\n   100→    if (ORIG_SLOW === undefined) {\n   101→      delete process.env.SLOW_THRESHOLD_MS;\n   102→    } else {\n   103→      process.env.SLOW_THRESHOLD_MS = ORIG_SLOW;\n   104→    }\n   105→  });\n   106→\n   107→  it('should be defined', () => {\n   108→    expect(service).toBeDefined();\n   109→  });\n   110→\n   111→  it('does nothing when no active tasks', async () => {\n   112→    taskStateService.findByStatuses.mockResolvedValue([]);\n   113→    await service.checkForStuckTasks();\n   114→    expect(taskStateService.save).not.toHaveBeenCalled();\n   115→  });\n   116→\n   117→  it('marks task as stuck when older than stuck threshold', async () => {\n   118→    const task = {\n   119→      id: 1,\n   120→      issue: 42,\n   121→      repo: 'AgentSDE/test',\n   122→      currentPhase: 'execute',\n   123→      status: 'active',\n   124→      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n   125→    } as TaskEntity;\n   126→\n   127→    taskStateService.findByStatuses.mockResolvedValue([task]);\n   128→    await service.checkForStuckTasks();\n   129→\n   130→    expect(task.status).toBe('stuck');\n   131→    expect(taskStateService.save).toHaveBeenCalledWith(task);\n   132→    expect(eventService.record).toHaveBeenCalledWith(\n   133→      1,\n   134→      'watchdog_detected_stuck',\n   135→      'watchdog',\n   136→      expect.objectContaining({ phase: 'execute' }),\n   137→    );\n   138→  });\n   139→\n   140→  it('includes lastEventAt and lastEventType in stuck event metadata', async () => {\n   141→    const task = {\n   142→      id: 1,\n   143→      issue: 42,\n   144→      repo: 'AgentSDE/test',\n   145→      currentPhase: 'execute',\n   146→      status: 'active',\n   147→      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n   148→    } as TaskEntity;\n   149→\n   150→    const lastEvent = {\n   151→      id: 10,\n   152→      taskId: 1,\n   153→      type: 'phase_started',\n   154→      source: 'dispatch',\n   155→      createdAt: new Date('2026-03-23T10:00:00.000Z'),\n   156→    } as EventEntity;\n   157→\n   158→    taskStateService.findByStatuses.mockResolvedValue([task]);\n   159→    (eventService.findByTask as jest.Mock).mockResolvedValue([lastEvent]);\n   160→\n   161→    await service.checkForStuckTasks();\n   162→\n   163→    expect(eventService.record).toHaveBeenCalledWith(\n   164→      1,\n   165→      'watchdog_detected_stuck',\n   166→      'watchdog',\n   167→      expect.objectContaining({\n   168→        lastEventType: 'phase_started',\n   169→        lastEventAt: '2026-03-23T10:00:00.000Z',\n   170→      }),\n   171→    );\n   172→  });\n   173→\n   174→  it('emits watchdog_detected_slow for tasks between slow and stuck thresholds', async () => {\n   175→    // 20 min old — past default slow threshold (15 min) but under stuck threshold (30 min)\n   176→    const task = {\n   177→      id: 2,\n   178→      issue: 10,\n   179→      repo: 'AgentSDE/test',\n   180→      currentPhase: 'plan',\n   181→      status: 'active',\n   182→      updatedAt: new Date(Date.now() - 20 * 60 * 1000),\n   183→    } as TaskEntity;\n   184→\n   185→    taskStateService.findByStatuses.mockResolvedValue([task]);\n   186→    (eventService.countByType as jest.Mock).mockResolvedValue(0);\n   187→\n   188→    await service.checkForStuckTasks();\n   189→\n   190→    expect(task.status).toBe('active');\n   191→    expect(taskStateService.save).not.toHaveBeenCalled();\n   192→    expect(eventService.record).toHaveBeenCalledWith(\n   193→      2,\n   194→      'watchdog_detected_slow',\n   195→      'watchdog',\n   196→      expect.objectContaining({ phase: 'plan' }),\n   197→    );\n   198→  });\n   199→\n   200→  it('does not re-emit watchdog_detected_slow if already emitted', async () => {\n   201→    const task = {\n   202→      id: 2,\n   203→      issue: 10,\n   204→      repo: 'AgentSDE/test',\n   205→      currentPhase: 'plan',\n   206→      status: 'active',\n   207→      updatedAt: new Date(Date.now() - 20 * 60 * 1000),\n   208→    } as TaskEntity;\n   209→\n   210→    taskStateService.findByStatuses.mockResolvedValue([task]);\n   211→    (eventService.countByType as jest.Mock).mockResolvedValue(1);\n   212→\n   213→    await service.checkForStuckTasks();\n   214→\n   215→    expect(eventService.record).not.toHaveBeenCalled();\n   216→  });\n   217→\n   218→  it('does NOT mark task as stuck when younger than threshold', async () => {\n   219→    const task = {\n   220→      id: 2,\n   221→      issue: 10,\n   222→      repo: 'AgentSDE/test',\n   223→      currentPhase: 'plan',\n   224→      status: 'active',\n   225→      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n   226→    } as TaskEntity;\n   227→\n   228→    taskStateService.findByStatuses.mockResolvedValue([task]);\n   229→    await service.checkForStuckTasks();\n   230→\n   231→    expect(taskStateService.save).not.toHaveBeenCalled();\n   232→    expect(eventService.record).not.toHaveBeenCalled();\n   233→  });\n   234→\n   235→  it('does not mark stuck when last event is within the stuck threshold', async () => {\n   236→    // updatedAt is old but a recent event was recorded (e.g. an intermediate log).\n   237→    // The watchdog should use the most-recent of updatedAt / lastEvent.createdAt.\n   238→    const task = {\n   239→      id: 3,\n   240→      issue: 20,\n   241→      repo: 'AgentSDE/test',\n   242→      currentPhase: 'execute',\n   243→      status: 'active',\n   244→      updatedAt: new Date(Date.now() - 35 * 60 * 1000), // 35 min ago\n   245→    } as TaskEntity;\n   246→\n   247→    const recentEvent = {\n   248→      id: 99,\n   249→      taskId: 3,\n   250→      type: 'phase_started',\n   251→      source: 'phase-router',\n   252→      createdAt: new Date(Date.now() - 5 * 60 * 1000), // 5 min ago\n   253→    } as EventEntity;\n   254→\n   255→    taskStateService.findByStatuses.mockResolvedValue([task]);\n   256→    (eventService.findByTask as jest.Mock).mockResolvedValue([recentEvent]);\n   257→\n   258→    await service.checkForStuckTasks();\n   259→\n   260→    // Task should remain active — last event is recent\n   261→    expect(task.status).toBe('active');\n   262→    expect(taskStateService.save).not.toHaveBeenCalled();\n   263→    expect(eventService.record).not.toHaveBeenCalled();\n   264→  });\n   265→\n   266→  it('marks only stuck tasks when mixed ages', async () => {\n   267→    const stuckTask = {\n   268→      id: 1,\n   269→      issue: 42,\n   270→      repo: 'AgentSDE/test',\n   271→      currentPhase: 'execute',\n   272→      status: 'active',\n   273→      updatedAt: new Date(Date.now() - 45 * 60 * 1000),\n   274→    } as TaskEntity;\n   275→\n   276→    const freshTask = {\n   277→      id: 2,\n   278→      issue: 10,\n   279→      repo: 'AgentSDE/test',\n   280→      currentPhase: 'plan',\n   281→      status: 'active',\n   282→      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n   283→    } as TaskEntity;\n   284→\n   285→    taskStateService.findByStatuses.mockResolvedValue([stuckTask, freshTask]);\n   286→    await service.checkForStuckTasks();\n   287→\n   288→    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n   289→    expect(taskStateService.save).toHaveBeenCalledWith(stuckTask);\n   290→  });\n   291→\n   292→  it('emits task.stuck when a stuck task is detected', async () => {\n   293→    const task = {\n   294→      id: 1,\n   295→      issue: 42,\n   296→      repo: 'AgentSDE/test',\n   297→      currentPhase: 'execute',\n   298→      status: 'active',\n   299→      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n   300→    } as TaskEntity;\n   301→\n   302→    taskStateService.findByStatuses.mockResolvedValue([task]);\n   303→    await service.checkForStuckTasks();\n   304→\n   305→    expect(eventBus.emit).toHaveBeenCalledWith(\n   306→      'task.stuck',\n   307→      expect.objectContaining({\n   308→        taskId: 1,\n   309→        issue: 42,\n   310→        repo: 'AgentSDE/test',\n   311→        phase: 'execute',\n   312→      }),\n   313→    );\n   314→  });\n   315→\n   316→  it('does not emit task.stuck for non-stuck tasks', async () => {\n   317→    const task = {\n   318→      id: 2,\n   319→      issue: 10,\n   320→      repo: 'AgentSDE/test',\n   321→      currentPhase: 'plan',\n   322→      status: 'active',\n   323→      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n   324→    } as TaskEntity;\n   325→\n   326→    taskStateService.findByStatuses.mockResolvedValue([task]);\n   327→    await service.checkForStuckTasks();\n   328→\n   329→    expect(eventBus.emit).not.toHaveBeenCalledWith(\n   330→      'task.stuck',\n   331→      expect.anything(),\n   332→    );\n   333→  });\n   334→\n   335→  it('calls onPhaseBlocked when a stuck task is detected', async () => {\n   336→    const task = {\n   337→      id: 1,\n   338→      issue: 42,\n   339→      repo: 'AgentSDE/test',\n   340→      currentPhase: 'execute',\n   341→      status: 'active',\n   342→      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n   343→    } as TaskEntity;\n   344→\n   345→    taskStateService.findByStatuses.mockResolvedValue([task]);\n   346→    await service.checkForStuckTasks();\n   347→\n   348→    expect(phaseHooks.onPhaseBlocked).toHaveBeenCalledWith(\n   349→      expect.objectContaining({ issue: 42, repo: 'AgentSDE/test' }),\n   350→      'execute',\n   351→      expect.stringContaining('stuck'),\n   352→      'stuck',\n   353→    );\n   354→  });\n   355→\n   356→  it('does not call onPhaseBlocked for non-stuck tasks', async () => {\n   357→    const task = {\n   358→      id: 2,\n   359→      issue: 10,\n   360→      repo: 'AgentSDE/test',\n   361→      currentPhase: 'plan',\n   362→      status: 'active',\n   363→      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n   364→    } as TaskEntity;\n   365→\n   366→    taskStateService.findByStatuses.mockResolvedValue([task]);\n   367→    await service.checkForStuckTasks();\n   368→\n   369→    expect(phaseHooks.onPhaseBlocked).not.toHaveBeenCalled();\n   370→  });\n   371→});\n   372→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"f140a4a4-e892-42fe-9d08-a312e9d02029","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/watchdog/watchdog.service.spec.ts","content":"/* eslint-disable @typescript-eslint/unbound-method */\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { WatchdogService } from './watchdog.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { EventService } from '../event/event.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventBusService } from '../event-bus';\nimport { TaskEntity } from '../database/entities';\nimport { EventEntity } from '../database/entities';\n\ndescribe('WatchdogService', () => {\n  let service: WatchdogService;\n  let taskStateService: jest.Mocked<TaskStateService>;\n  let eventService: jest.Mocked<EventService>;\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let phaseHooks: jest.Mocked<PhaseHooksService>;\n  let originalStuck: string | undefined;\n  let originalSlow: string | undefined;\n\n  beforeAll(() => {\n    // Pin thresholds so tests are not affected by environment configuration.\n    originalStuck = process.env.STUCK_THRESHOLD_MS;\n    originalSlow = process.env.SLOW_THRESHOLD_MS;\n    process.env.STUCK_THRESHOLD_MS = '1800000'; // 30 min\n    process.env.SLOW_THRESHOLD_MS = '900000'; // 15 min\n  });\n\n  afterAll(() => {\n    if (originalStuck !== undefined) {\n      process.env.STUCK_THRESHOLD_MS = originalStuck;\n    } else {\n      delete process.env.STUCK_THRESHOLD_MS;\n    }\n    if (originalSlow !== undefined) {\n      process.env.SLOW_THRESHOLD_MS = originalSlow;\n    } else {\n      delete process.env.SLOW_THRESHOLD_MS;\n    }\n  });\n\n  // Pin thresholds so tests are independent of the runtime environment.\n  // WatchdogService reads these from process.env at instantiation time.\n  const ORIG_STUCK = process.env.STUCK_THRESHOLD_MS;\n  const ORIG_SLOW = process.env.SLOW_THRESHOLD_MS;\n\n  beforeEach(async () => {\n    process.env.STUCK_THRESHOLD_MS = '1800000'; // 30 min\n    process.env.SLOW_THRESHOLD_MS = '900000'; // 15 min\n\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        WatchdogService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByStatus: jest.fn().mockResolvedValue([]),\n            findByStatuses: jest.fn().mockResolvedValue([]),\n            save: jest\n              .fn()\n              .mockImplementation((t: TaskEntity) => Promise.resolve(t)),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn().mockResolvedValue({}),\n            findByTask: jest.fn().mockResolvedValue([]),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseBlocked: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get<WatchdogService>(WatchdogService);\n    taskStateService = module.get(TaskStateService);\n    eventService = module.get(EventService);\n    eventBus = module.get(EventBusService);\n    phaseHooks = module.get(PhaseHooksService);\n  });\n\n  afterEach(() => {\n    if (ORIG_STUCK === undefined) {\n      delete process.env.STUCK_THRESHOLD_MS;\n    } else {\n      process.env.STUCK_THRESHOLD_MS = ORIG_STUCK;\n    }\n    if (ORIG_SLOW === undefined) {\n      delete process.env.SLOW_THRESHOLD_MS;\n    } else {\n      process.env.SLOW_THRESHOLD_MS = ORIG_SLOW;\n    }\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('does nothing when no active tasks', async () => {\n    taskStateService.findByStatuses.mockResolvedValue([]);\n    await service.checkForStuckTasks();\n    expect(taskStateService.save).not.toHaveBeenCalled();\n  });\n\n  it('marks task as stuck when older than stuck threshold', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'execute',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(task.status).toBe('stuck');\n    expect(taskStateService.save).toHaveBeenCalledWith(task);\n    expect(eventService.record).toHaveBeenCalledWith(\n      1,\n      'watchdog_detected_stuck',\n      'watchdog',\n      expect.objectContaining({ phase: 'execute' }),\n    );\n  });\n\n  it('includes lastEventAt and lastEventType in stuck event metadata', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'execute',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    const lastEvent = {\n      id: 10,\n      taskId: 1,\n      type: 'phase_started',\n      source: 'dispatch',\n      createdAt: new Date('2026-03-23T10:00:00.000Z'),\n    } as EventEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.findByTask as jest.Mock).mockResolvedValue([lastEvent]);\n\n    await service.checkForStuckTasks();\n\n    expect(eventService.record).toHaveBeenCalledWith(\n      1,\n      'watchdog_detected_stuck',\n      'watchdog',\n      expect.objectContaining({\n        lastEventType: 'phase_started',\n        lastEventAt: '2026-03-23T10:00:00.000Z',\n      }),\n    );\n  });\n\n  it('emits watchdog_detected_slow for tasks between slow and stuck thresholds', async () => {\n    // 20 min old — past default slow threshold (15 min) but under stuck threshold (30 min)\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 20 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.countByType as jest.Mock).mockResolvedValue(0);\n\n    await service.checkForStuckTasks();\n\n    expect(task.status).toBe('active');\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(eventService.record).toHaveBeenCalledWith(\n      2,\n      'watchdog_detected_slow',\n      'watchdog',\n      expect.objectContaining({ phase: 'plan' }),\n    );\n  });\n\n  it('does not re-emit watchdog_detected_slow if already emitted', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 20 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.countByType as jest.Mock).mockResolvedValue(1);\n\n    await service.checkForStuckTasks();\n\n    expect(eventService.record).not.toHaveBeenCalled();\n  });\n\n  it('does NOT mark task as stuck when younger than threshold', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(eventService.record).not.toHaveBeenCalled();\n  });\n\n  it('does not mark stuck when last event is within the stuck threshold', async () => {\n    // updatedAt is old but a recent event was recorded (e.g. an intermediate log).\n    // The watchdog should use the most-recent of updatedAt / lastEvent.createdAt.\n    const task = {\n      id: 3,\n      issue: 20,\n      repo: 'AgentSDE/test',\n      currentPhase: 'execute',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000), // 35 min ago\n    } as TaskEntity;\n\n    const recentEvent = {\n      id: 99,\n      taskId: 3,\n      type: 'phase_started',\n      source: 'phase-router',\n      createdAt: new Date(Date.now() - 5 * 60 * 1000), // 5 min ago\n    } as EventEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.findByTask as jest.Mock).mockResolvedValue([recentEvent]);\n\n    await service.checkForStuckTasks();\n\n    // Task should remain active — last event is recent\n    expect(task.status).toBe('active');\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(eventService.record).not.toHaveBeenCalled();\n  });\n\n  it('marks only stuck tasks when mixed ages', async () => {\n    const stuckTask = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'execute',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 45 * 60 * 1000),\n    } as TaskEntity;\n\n    const freshTask = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([stuckTask, freshTask]);\n    await service.checkForStuckTasks();\n\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(taskStateService.save).toHaveBeenCalledWith(stuckTask);\n  });\n\n  it('emits task.stuck when a stuck task is detected', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'execute',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(eventBus.emit).toHaveBeenCalledWith(\n      'task.stuck',\n      expect.objectContaining({\n        taskId: 1,\n        issue: 42,\n        repo: 'AgentSDE/test',\n        phase: 'execute',\n      }),\n    );\n  });\n\n  it('does not emit task.stuck for non-stuck tasks', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(eventBus.emit).not.toHaveBeenCalledWith(\n      'task.stuck',\n      expect.anything(),\n    );\n  });\n\n  it('calls onPhaseBlocked when a stuck task is detected', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'execute',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(phaseHooks.onPhaseBlocked).toHaveBeenCalledWith(\n      expect.objectContaining({ issue: 42, repo: 'AgentSDE/test' }),\n      'execute',\n      expect.stringContaining('stuck'),\n      'stuck',\n    );\n  });\n\n  it('does not call onPhaseBlocked for non-stuck tasks', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(phaseHooks.onPhaseBlocked).not.toHaveBeenCalled();\n  });\n});\n","numLines":372,"startLine":1,"totalLines":372}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_019s1QhNoKoMkdf9zsNmweLZ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013uPLaGSNGgc7qdTa42BUUn","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/invoke/claude-invocation.service.spec.ts"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":20260,"cache_read_input_tokens":31565,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":20260},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"e7593041-c161-408e-85fb-5873cacc6fb5"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013uPLaGSNGgc7qdTa42BUUn","type":"tool_result","content":"     1→/* eslint-disable @typescript-eslint/unbound-method */\n     2→import { ConfigService } from '@nestjs/config';\n     3→import { existsSync, mkdirSync, rmSync } from 'node:fs';\n     4→import { join } from 'node:path';\n     5→import { tmpdir } from 'node:os';\n     6→import { ClaudeInvocationService } from './claude-invocation.service';\n     7→import type { EventService } from '../event/event.service';\n     8→import type { Queue } from 'bullmq';\n     9→\n    10→// ---------------------------------------------------------------------------\n    11→// helpers\n    12→// ---------------------------------------------------------------------------\n    13→\n    14→function makeEventService(): EventService {\n    15→  return {\n    16→    record: jest.fn().mockResolvedValue({}),\n    17→  } as unknown as EventService;\n    18→}\n    19→\n    20→function makePhaseInvokeQueue(): Queue {\n    21→  return {\n    22→    add: jest.fn().mockResolvedValue({ id: 'mock-job-id' }),\n    23→  } as unknown as Queue;\n    24→}\n    25→\n    26→/** Create a unique temp directory for a test run. */\n    27→function createTestTmpDir(): string {\n    28→  const dir = join(\n    29→    tmpdir(),\n    30→    `agent-core-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,\n    31→  );\n    32→  mkdirSync(dir, { recursive: true });\n    33→  return dir;\n    34→}\n    35→\n    36→function makeConfigService(\n    37→  overrides: Record<string, unknown> = {},\n    38→): ConfigService {\n    39→  return {\n    40→    get: jest.fn((key: string, defaultValue?: unknown) => {\n    41→      if (key in overrides) return overrides[key];\n    42→      if (key === 'CLAUDE_TIMEOUT_SECS') return 3600;\n    43→      return defaultValue;\n    44→    }),\n    45→  } as unknown as ConfigService;\n    46→}\n    47→\n    48→/** Extract the payload passed to queue.add() in the first call. */\n    49→function getAddPayload(queue: Queue): Record<string, unknown> {\n    50→  const calls = (queue.add as jest.Mock).mock.calls as Array<\n    51→    [string, Record<string, unknown>]\n    52→  >;\n    53→  return calls[0][1];\n    54→}\n    55→\n    56→function buildService(\n    57→  opts: {\n    58→    config?: ConfigService;\n    59→    eventService?: EventService;\n    60→    queue?: Queue;\n    61→  } = {},\n    62→) {\n    63→  return new ClaudeInvocationService(\n    64→    opts.queue ?? makePhaseInvokeQueue(),\n    65→    opts.config ?? makeConfigService(),\n    66→    opts.eventService ?? makeEventService(),\n    67→  );\n    68→}\n    69→\n    70→// ---------------------------------------------------------------------------\n    71→// tests\n    72→// ---------------------------------------------------------------------------\n    73→\n    74→describe('ClaudeInvocationService', () => {\n    75→  let testTmpDir: string;\n    76→\n    77→  beforeEach(() => {\n    78→    testTmpDir = createTestTmpDir();\n    79→  });\n    80→\n    81→  afterEach(() => {\n    82→    if (testTmpDir && existsSync(testTmpDir)) {\n    83→      rmSync(testTmpDir, { recursive: true, force: true });\n    84→    }\n    85→  });\n    86→\n    87→  // -----------------------------------------------------------------------\n    88→  // Fire-and-forget behavior\n    89→  // -----------------------------------------------------------------------\n    90→\n    91→  it('enqueues job and returns void', async () => {\n    92→    const queue = makePhaseInvokeQueue();\n    93→    const svc = buildService({ queue });\n    94→    const result = await svc.invoke(\n    95→      'plan',\n    96→      { WORKTREE_PATH: testTmpDir, ISSUE_NUMBER: '1' },\n    97→      1,\n    98→    );\n    99→\n   100→    expect(result).toBeUndefined();\n   101→    expect(queue.add).toHaveBeenCalledTimes(1);\n   102→  });\n   103→\n   104→  it('records enqueued event', async () => {\n   105→    const eventService = makeEventService();\n   106→    const svc = buildService({ eventService });\n   107→    await svc.invoke(\n   108→      'plan',\n   109→      { WORKTREE_PATH: testTmpDir, ISSUE_NUMBER: '10' },\n   110→      10,\n   111→    );\n   112→\n   113→    expect(eventService.record).toHaveBeenCalledWith(\n   114→      10,\n   115→      'claude_invocation_enqueued',\n   116→      'internal',\n   117→      expect.objectContaining({ skill: 'plan' }),\n   118→    );\n   119→  });\n   120→\n   121→  // -----------------------------------------------------------------------\n   122→  // Phase-to-skill mapping (verified via queue payload)\n   123→  // -----------------------------------------------------------------------\n   124→\n   125→  it('maps refine phase to product-manager skill', async () => {\n   126→    const queue = makePhaseInvokeQueue();\n   127→    const svc = buildService({ queue });\n   128→    await svc.invoke(\n   129→      'refine',\n   130→      {\n   131→        ISSUE_NUMBER: '40',\n   132→        REPO: 'AgentSDE/test-repo',\n   133→        WORKTREE_PATH: testTmpDir,\n   134→      },\n   135→      40,\n   136→    );\n   137→    expect(getAddPayload(queue)).toMatchObject({ skill: 'product-manager' });\n   138→  });\n   139→\n   140→  it('maps plan phase to plan-and-propose skill', async () => {\n   141→    const queue = makePhaseInvokeQueue();\n   142→    const svc = buildService({ queue });\n   143→    await svc.invoke(\n   144→      'plan',\n   145→      {\n   146→        ISSUE_NUMBER: '41',\n   147→        REPO: 'AgentSDE/test-repo',\n   148→        WORKTREE_PATH: testTmpDir,\n   149→      },\n   150→      41,\n   151→    );\n   152→    expect(getAddPayload(queue)).toMatchObject({ skill: 'plan-and-propose' });\n   153→  });\n   154→\n   155→  it('maps review phase to review-pr skill', async () => {\n   156→    const queue = makePhaseInvokeQueue();\n   157→    const svc = buildService({ queue });\n   158→    await svc.invoke(\n   159→      'review',\n   160→      {\n   161→        ISSUE_NUMBER: '43',\n   162→        REPO: 'AgentSDE/test-repo',\n   163→        WORKTREE_PATH: testTmpDir,\n   164→      },\n   165→      43,\n   166→    );\n   167→    expect(getAddPayload(queue)).toMatchObject({ skill: 'review-pr' });\n   168→  });\n   169→\n   170→  it('maps deliver phase to deliver-implementation skill', async () => {\n   171→    const queue = makePhaseInvokeQueue();\n   172→    const svc = buildService({ queue });\n   173→    await svc.invoke(\n   174→      'deliver',\n   175→      {\n   176→        ISSUE_NUMBER: '44',\n   177→        REPO: 'AgentSDE/test-repo',\n   178→        WORKTREE_PATH: testTmpDir,\n   179→      },\n   180→      44,\n   181→    );\n   182→    expect(getAddPayload(queue)).toMatchObject({\n   183→      skill: 'deliver-implementation',\n   184→    });\n   185→  });\n   186→\n   187→  it('passes unknown skill name through unchanged', async () => {\n   188→    const queue = makePhaseInvokeQueue();\n   189→    const svc = buildService({ queue });\n   190→    await svc.invoke(\n   191→      'custom-skill',\n   192→      { WORKTREE_PATH: testTmpDir, ISSUE_NUMBER: '45' },\n   193→      45,\n   194→    );\n   195→    expect(getAddPayload(queue)).toMatchObject({ skill: 'custom-skill' });\n   196→  });\n   197→\n   198→  // -----------------------------------------------------------------------\n   199→  // Argument string passing (verified via queue payload.args)\n   200→  // -----------------------------------------------------------------------\n   201→\n   202→  it('passes COMMENT_ID as skill argument when provided', async () => {\n   203→    const queue = makePhaseInvokeQueue();\n   204→    const svc = buildService({ queue });\n   205→    await svc.invoke(\n   206→      'reply_to_pr_comment',\n   207→      {\n   208→        ISSUE_NUMBER: '48',\n   209→        PR_NUMBER: '100',\n   210→        COMMENT_ID: '99999',\n   211→        REPO: 'AgentSDE/test-repo',\n   212→        WORKTREE_PATH: testTmpDir,\n   213→      },\n   214→      48,\n   215→    );\n   216→    expect(getAddPayload(queue)['args']).toContain('COMMENT_ID=99999');\n   217→  });\n   218→\n   219→  it('passes DIRECTIVE as skill argument', async () => {\n   220→    const queue = makePhaseInvokeQueue();\n   221→    const svc = buildService({ queue });\n   222→    await svc.invoke(\n   223→      'plan',\n   224→      { ISSUE_NUMBER: '42', DIRECTIVE: 'auto', WORKTREE_PATH: testTmpDir },\n   225→      54,\n   226→    );\n   227→    expect(getAddPayload(queue)['args']).toContain('DIRECTIVE=auto');\n   228→  });\n   229→\n   230→  it('passes PR_BASE_BRANCH as skill argument', async () => {\n   231→    const queue = makePhaseInvokeQueue();\n   232→    const svc = buildService({ queue });\n   233→    await svc.invoke(\n   234→      'deliver',\n   235→      {\n   236→        ISSUE_NUMBER: '42',\n   237→        PR_BASE_BRANCH: 'rc/v1.0',\n   238→        WORKTREE_PATH: testTmpDir,\n   239→      },\n   240→      55,\n   241→    );\n   242→    expect(getAddPayload(queue)['args']).toContain('PR_BASE_BRANCH=rc/v1.0');\n   243→  });\n   244→\n   245→  // -----------------------------------------------------------------------\n   246→  // Queue payload fields\n   247→  // -----------------------------------------------------------------------\n   248→\n   249→  it('enqueues job with all required payload fields', async () => {\n   250→    const queue = makePhaseInvokeQueue();\n   251→    const svc = buildService({ queue });\n   252→    await svc.invoke(\n   253→      'plan',\n   254→      {\n   255→        ISSUE_NUMBER: '70',\n   256→        REPO: 'AgentSDE/ai-agents-sde',\n   257→        DIRECTIVE: 'auto',\n   258→        WORKTREE_PATH: testTmpDir,\n   259→      },\n   260→      70,\n   261→    );\n   262→\n   263→    const payload = getAddPayload(queue);\n   264→    expect(payload).toMatchObject({\n   265→      phase: 'plan',\n   266→      skill: 'plan-and-propose',\n   267→      provider: 'claude',\n   268→      timeout: 3600,\n   269→    });\n   270→    expect(typeof payload['jobId']).toBe('string');\n   271→    expect(typeof payload['args']).toBe('string');\n   272→    expect(payload['args']).toContain('ISSUE_NUMBER=70');\n   273→    expect(payload['args']).toContain('DIRECTIVE=auto');\n   274→  });\n   275→\n   276→  // -----------------------------------------------------------------------\n   277→  // TARGET_REPO_PATH\n   278→  // -----------------------------------------------------------------------\n   279→\n   280→  it('sets TARGET_REPO_PATH in env from WORKTREE_PATH', async () => {\n   281→    const queue = makePhaseInvokeQueue();\n   282→    const svc = buildService({ queue });\n   283→    await svc.invoke(\n   284→      'plan',\n   285→      {\n   286→        REPO: 'AgentSDE/my-repo',\n   287→        WORKTREE_PATH: testTmpDir,\n   288→        ISSUE_NUMBER: '61',\n   289→      },\n   290→      61,\n   291→    );\n   292→\n   293→    const payload = getAddPayload(queue);\n   294→    const env = payload['env'] as Record<string, string>;\n   295→    expect(env['TARGET_REPO_PATH']).toBe(testTmpDir);\n   296→  });\n   297→\n   298→  // -----------------------------------------------------------------------\n   299→  // Task directory creation\n   300→  // -----------------------------------------------------------------------\n   301→\n   302→  it('creates task directory structure', async () => {\n   303→    const svc = buildService();\n   304→    await svc.invoke(\n   305→      'plan',\n   306→      { ISSUE_NUMBER: '99', WORKTREE_PATH: testTmpDir },\n   307→      52,\n   308→    );\n   309→\n   310→    const taskDir = join(testTmpDir, 'tasks', '99');\n   311→    expect(existsSync(join(taskDir, 'meta'))).toBe(true);\n   312→    expect(existsSync(join(taskDir, 'plan'))).toBe(true);\n   313→    expect(existsSync(join(taskDir, 'input'))).toBe(true);\n   314→  });\n   315→});\n   316→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"0ee698c5-7e62-442b-8b89-f8f6fb317cd1","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/invoke/claude-invocation.service.spec.ts","content":"/* eslint-disable @typescript-eslint/unbound-method */\nimport { ConfigService } from '@nestjs/config';\nimport { existsSync, mkdirSync, rmSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { tmpdir } from 'node:os';\nimport { ClaudeInvocationService } from './claude-invocation.service';\nimport type { EventService } from '../event/event.service';\nimport type { Queue } from 'bullmq';\n\n// ---------------------------------------------------------------------------\n// helpers\n// ---------------------------------------------------------------------------\n\nfunction makeEventService(): EventService {\n  return {\n    record: jest.fn().mockResolvedValue({}),\n  } as unknown as EventService;\n}\n\nfunction makePhaseInvokeQueue(): Queue {\n  return {\n    add: jest.fn().mockResolvedValue({ id: 'mock-job-id' }),\n  } as unknown as Queue;\n}\n\n/** Create a unique temp directory for a test run. */\nfunction createTestTmpDir(): string {\n  const dir = join(\n    tmpdir(),\n    `agent-core-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,\n  );\n  mkdirSync(dir, { recursive: true });\n  return dir;\n}\n\nfunction makeConfigService(\n  overrides: Record<string, unknown> = {},\n): ConfigService {\n  return {\n    get: jest.fn((key: string, defaultValue?: unknown) => {\n      if (key in overrides) return overrides[key];\n      if (key === 'CLAUDE_TIMEOUT_SECS') return 3600;\n      return defaultValue;\n    }),\n  } as unknown as ConfigService;\n}\n\n/** Extract the payload passed to queue.add() in the first call. */\nfunction getAddPayload(queue: Queue): Record<string, unknown> {\n  const calls = (queue.add as jest.Mock).mock.calls as Array<\n    [string, Record<string, unknown>]\n  >;\n  return calls[0][1];\n}\n\nfunction buildService(\n  opts: {\n    config?: ConfigService;\n    eventService?: EventService;\n    queue?: Queue;\n  } = {},\n) {\n  return new ClaudeInvocationService(\n    opts.queue ?? makePhaseInvokeQueue(),\n    opts.config ?? makeConfigService(),\n    opts.eventService ?? makeEventService(),\n  );\n}\n\n// ---------------------------------------------------------------------------\n// tests\n// ---------------------------------------------------------------------------\n\ndescribe('ClaudeInvocationService', () => {\n  let testTmpDir: string;\n\n  beforeEach(() => {\n    testTmpDir = createTestTmpDir();\n  });\n\n  afterEach(() => {\n    if (testTmpDir && existsSync(testTmpDir)) {\n      rmSync(testTmpDir, { recursive: true, force: true });\n    }\n  });\n\n  // -----------------------------------------------------------------------\n  // Fire-and-forget behavior\n  // -----------------------------------------------------------------------\n\n  it('enqueues job and returns void', async () => {\n    const queue = makePhaseInvokeQueue();\n    const svc = buildService({ queue });\n    const result = await svc.invoke(\n      'plan',\n      { WORKTREE_PATH: testTmpDir, ISSUE_NUMBER: '1' },\n      1,\n    );\n\n    expect(result).toBeUndefined();\n    expect(queue.add).toHaveBeenCalledTimes(1);\n  });\n\n  it('records enqueued event', async () => {\n    const eventService = makeEventService();\n    const svc = buildService({ eventService });\n    await svc.invoke(\n      'plan',\n      { WORKTREE_PATH: testTmpDir, ISSUE_NUMBER: '10' },\n      10,\n    );\n\n    expect(eventService.record).toHaveBeenCalledWith(\n      10,\n      'claude_invocation_enqueued',\n      'internal',\n      expect.objectContaining({ skill: 'plan' }),\n    );\n  });\n\n  // -----------------------------------------------------------------------\n  // Phase-to-skill mapping (verified via queue payload)\n  // -----------------------------------------------------------------------\n\n  it('maps refine phase to product-manager skill', async () => {\n    const queue = makePhaseInvokeQueue();\n    const svc = buildService({ queue });\n    await svc.invoke(\n      'refine',\n      {\n        ISSUE_NUMBER: '40',\n        REPO: 'AgentSDE/test-repo',\n        WORKTREE_PATH: testTmpDir,\n      },\n      40,\n    );\n    expect(getAddPayload(queue)).toMatchObject({ skill: 'product-manager' });\n  });\n\n  it('maps plan phase to plan-and-propose skill', async () => {\n    const queue = makePhaseInvokeQueue();\n    const svc = buildService({ queue });\n    await svc.invoke(\n      'plan',\n      {\n        ISSUE_NUMBER: '41',\n        REPO: 'AgentSDE/test-repo',\n        WORKTREE_PATH: testTmpDir,\n      },\n      41,\n    );\n    expect(getAddPayload(queue)).toMatchObject({ skill: 'plan-and-propose' });\n  });\n\n  it('maps review phase to review-pr skill', async () => {\n    const queue = makePhaseInvokeQueue();\n    const svc = buildService({ queue });\n    await svc.invoke(\n      'review',\n      {\n        ISSUE_NUMBER: '43',\n        REPO: 'AgentSDE/test-repo',\n        WORKTREE_PATH: testTmpDir,\n      },\n      43,\n    );\n    expect(getAddPayload(queue)).toMatchObject({ skill: 'review-pr' });\n  });\n\n  it('maps deliver phase to deliver-implementation skill', async () => {\n    const queue = makePhaseInvokeQueue();\n    const svc = buildService({ queue });\n    await svc.invoke(\n      'deliver',\n      {\n        ISSUE_NUMBER: '44',\n        REPO: 'AgentSDE/test-repo',\n        WORKTREE_PATH: testTmpDir,\n      },\n      44,\n    );\n    expect(getAddPayload(queue)).toMatchObject({\n      skill: 'deliver-implementation',\n    });\n  });\n\n  it('passes unknown skill name through unchanged', async () => {\n    const queue = makePhaseInvokeQueue();\n    const svc = buildService({ queue });\n    await svc.invoke(\n      'custom-skill',\n      { WORKTREE_PATH: testTmpDir, ISSUE_NUMBER: '45' },\n      45,\n    );\n    expect(getAddPayload(queue)).toMatchObject({ skill: 'custom-skill' });\n  });\n\n  // -----------------------------------------------------------------------\n  // Argument string passing (verified via queue payload.args)\n  // -----------------------------------------------------------------------\n\n  it('passes COMMENT_ID as skill argument when provided', async () => {\n    const queue = makePhaseInvokeQueue();\n    const svc = buildService({ queue });\n    await svc.invoke(\n      'reply_to_pr_comment',\n      {\n        ISSUE_NUMBER: '48',\n        PR_NUMBER: '100',\n        COMMENT_ID: '99999',\n        REPO: 'AgentSDE/test-repo',\n        WORKTREE_PATH: testTmpDir,\n      },\n      48,\n    );\n    expect(getAddPayload(queue)['args']).toContain('COMMENT_ID=99999');\n  });\n\n  it('passes DIRECTIVE as skill argument', async () => {\n    const queue = makePhaseInvokeQueue();\n    const svc = buildService({ queue });\n    await svc.invoke(\n      'plan',\n      { ISSUE_NUMBER: '42', DIRECTIVE: 'auto', WORKTREE_PATH: testTmpDir },\n      54,\n    );\n    expect(getAddPayload(queue)['args']).toContain('DIRECTIVE=auto');\n  });\n\n  it('passes PR_BASE_BRANCH as skill argument', async () => {\n    const queue = makePhaseInvokeQueue();\n    const svc = buildService({ queue });\n    await svc.invoke(\n      'deliver',\n      {\n        ISSUE_NUMBER: '42',\n        PR_BASE_BRANCH: 'rc/v1.0',\n        WORKTREE_PATH: testTmpDir,\n      },\n      55,\n    );\n    expect(getAddPayload(queue)['args']).toContain('PR_BASE_BRANCH=rc/v1.0');\n  });\n\n  // -----------------------------------------------------------------------\n  // Queue payload fields\n  // -----------------------------------------------------------------------\n\n  it('enqueues job with all required payload fields', async () => {\n    const queue = makePhaseInvokeQueue();\n    const svc = buildService({ queue });\n    await svc.invoke(\n      'plan',\n      {\n        ISSUE_NUMBER: '70',\n        REPO: 'AgentSDE/ai-agents-sde',\n        DIRECTIVE: 'auto',\n        WORKTREE_PATH: testTmpDir,\n      },\n      70,\n    );\n\n    const payload = getAddPayload(queue);\n    expect(payload).toMatchObject({\n      phase: 'plan',\n      skill: 'plan-and-propose',\n      provider: 'claude',\n      timeout: 3600,\n    });\n    expect(typeof payload['jobId']).toBe('string');\n    expect(typeof payload['args']).toBe('string');\n    expect(payload['args']).toContain('ISSUE_NUMBER=70');\n    expect(payload['args']).toContain('DIRECTIVE=auto');\n  });\n\n  // -----------------------------------------------------------------------\n  // TARGET_REPO_PATH\n  // -----------------------------------------------------------------------\n\n  it('sets TARGET_REPO_PATH in env from WORKTREE_PATH', async () => {\n    const queue = makePhaseInvokeQueue();\n    const svc = buildService({ queue });\n    await svc.invoke(\n      'plan',\n      {\n        REPO: 'AgentSDE/my-repo',\n        WORKTREE_PATH: testTmpDir,\n        ISSUE_NUMBER: '61',\n      },\n      61,\n    );\n\n    const payload = getAddPayload(queue);\n    const env = payload['env'] as Record<string, string>;\n    expect(env['TARGET_REPO_PATH']).toBe(testTmpDir);\n  });\n\n  // -----------------------------------------------------------------------\n  // Task directory creation\n  // -----------------------------------------------------------------------\n\n  it('creates task directory structure', async () => {\n    const svc = buildService();\n    await svc.invoke(\n      'plan',\n      { ISSUE_NUMBER: '99', WORKTREE_PATH: testTmpDir },\n      52,\n    );\n\n    const taskDir = join(testTmpDir, 'tasks', '99');\n    expect(existsSync(join(taskDir, 'meta'))).toBe(true);\n    expect(existsSync(join(taskDir, 'plan'))).toBe(true);\n    expect(existsSync(join(taskDir, 'input'))).toBe(true);\n  });\n});\n","numLines":316,"startLine":1,"totalLines":316}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_019s1QhNoKoMkdf9zsNmweLZ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VoRUq25V7HPkm9SFseEj8B","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/metrics/metrics.service.spec.ts"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":20260,"cache_read_input_tokens":31565,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":20260},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"86ad6f1c-fe7f-495d-b10d-60417270656e"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01VoRUq25V7HPkm9SFseEj8B","type":"tool_result","content":"     1→import { Test, TestingModule } from '@nestjs/testing';\n     2→import { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm';\n     3→import { Repository, DataSource } from 'typeorm';\n     4→import { TaskEntity, EventEntity } from '../database';\n     5→import { MetricsService } from './metrics.service';\n     6→import { ExcludedReposService } from '../config/excluded-repos.service';\n     7→\n     8→describe('MetricsService', () => {\n     9→  let module: TestingModule;\n    10→  let service: MetricsService;\n    11→  let taskRepo: Repository<TaskEntity>;\n    12→  let dataSource: DataSource;\n    13→\n    14→  beforeAll(async () => {\n    15→    module = await Test.createTestingModule({\n    16→      imports: [\n    17→        TypeOrmModule.forRoot({\n    18→          type: 'better-sqlite3',\n    19→          database: ':memory:',\n    20→          entities: [TaskEntity, EventEntity],\n    21→          synchronize: true,\n    22→        }),\n    23→        TypeOrmModule.forFeature([TaskEntity, EventEntity]),\n    24→      ],\n    25→      providers: [\n    26→        MetricsService,\n    27→        {\n    28→          provide: ExcludedReposService,\n    29→          useValue: {\n    30→            getList: (): string[] => ['AgentSDE/agent-core-e2e'],\n    31→            isExcluded: (repo: string): boolean =>\n    32→              repo === 'AgentSDE/agent-core-e2e',\n    33→          },\n    34→        },\n    35→      ],\n    36→    }).compile();\n    37→\n    38→    service = module.get<MetricsService>(MetricsService);\n    39→    taskRepo = module.get<Repository<TaskEntity>>(\n    40→      getRepositoryToken(TaskEntity),\n    41→    );\n    42→    dataSource = module.get<DataSource>(DataSource);\n    43→  });\n    44→\n    45→  afterAll(async () => {\n    46→    await module.close();\n    47→  });\n    48→\n    49→  beforeEach(async () => {\n    50→    await dataSource.query('DELETE FROM events');\n    51→    await dataSource.query('DELETE FROM tasks');\n    52→  });\n    53→\n    54→  async function createTask(\n    55→    issue: number,\n    56→    repo: string,\n    57→    status = 'active',\n    58→    startedAt: Date | null = null,\n    59→    completedAt: Date | null = null,\n    60→  ): Promise<TaskEntity> {\n    61→    const task = taskRepo.create({ issue, repo, status });\n    62→    task.startedAt = startedAt;\n    63→    task.completedAt = completedAt;\n    64→    return taskRepo.save(task);\n    65→  }\n    66→\n    67→  describe('computeSummary()', () => {\n    68→    it('returns correct shape with zero tasks', async () => {\n    69→      const summary = await service.computeSummary('30d');\n    70→\n    71→      expect(summary.total).toBe(0);\n    72→      expect(summary.byStatus).toEqual({});\n    73→      expect(summary.successRate).toBe(0);\n    74→      expect(summary.activeCount).toBe(0);\n    75→      expect(summary.inProgressCount).toBe(0);\n    76→      expect(summary.avgCompletionMs).toBeNull();\n    77→    });\n    78→\n    79→    it('counts total tasks correctly', async () => {\n    80→      await createTask(1, 'owner/repo-a');\n    81→      await createTask(2, 'owner/repo-b');\n    82→      await createTask(3, 'owner/repo-c');\n    83→\n    84→      const summary = await service.computeSummary('30d');\n    85→\n    86→      expect(summary.total).toBe(3);\n    87→    });\n    88→\n    89→    it('groups byStatus correctly', async () => {\n    90→      await createTask(1, 'owner/repo-a', 'active');\n    91→      await createTask(2, 'owner/repo-b', 'active');\n    92→      await createTask(3, 'owner/repo-c', 'completed');\n    93→      await createTask(4, 'owner/repo-d', 'failed');\n    94→\n    95→      const summary = await service.computeSummary('30d');\n    96→\n    97→      expect(summary.byStatus['active']).toBe(2);\n    98→      expect(summary.byStatus['completed']).toBe(1);\n    99→      expect(summary.byStatus['failed']).toBe(1);\n   100→    });\n   101→\n   102→    it('computes successRate as completed / (completed + failed)', async () => {\n   103→      await createTask(1, 'owner/repo-a', 'complete');\n   104→      await createTask(2, 'owner/repo-b', 'complete');\n   105→      await createTask(3, 'owner/repo-c', 'failed');\n   106→\n   107→      const summary = await service.computeSummary('30d');\n   108→\n   109→      expect(summary.successRate).toBeCloseTo(2 / 3);\n   110→    });\n   111→\n   112→    it('returns successRate 0 when no completed or failed tasks', async () => {\n   113→      await createTask(1, 'owner/repo-a', 'active');\n   114→\n   115→      const summary = await service.computeSummary('30d');\n   116→\n   117→      expect(summary.successRate).toBe(0);\n   118→    });\n   119→\n   120→    it('returns activeCount for active tasks', async () => {\n   121→      await createTask(1, 'owner/repo-a', 'active');\n   122→      await createTask(2, 'owner/repo-b', 'active');\n   123→      await createTask(3, 'owner/repo-c', 'completed');\n   124→\n   125→      const summary = await service.computeSummary('30d');\n   126→\n   127→      expect(summary.activeCount).toBe(2);\n   128→    });\n   129→\n   130→    it('computes avgCompletionMs when startedAt/completedAt are set', async () => {\n   131→      const start = new Date('2026-01-01T00:00:00.000Z');\n   132→      const end = new Date('2026-01-01T00:01:00.000Z'); // 60_000 ms later\n   133→\n   134→      await createTask(1, 'owner/repo-a', 'completed', start, end);\n   135→\n   136→      const summary = await service.computeSummary('30d');\n   137→\n   138→      expect(summary.avgCompletionMs).not.toBeNull();\n   139→      expect(summary.avgCompletionMs).toBeCloseTo(60_000, -1);\n   140→    });\n   141→\n   142→    it('returns avgCompletionMs null when no tasks with both dates', async () => {\n   143→      await createTask(1, 'owner/repo-a', 'active');\n   144→\n   145→      const summary = await service.computeSummary('30d');\n   146→\n   147→      expect(summary.avgCompletionMs).toBeNull();\n   148→    });\n   149→\n   150→    it('computes successRate and inProgressCount for issue scenario (27 complete, 10 failed, 5 stopped, 3 stuck, 23 blocked, 15 gated)', async () => {\n   151→      for (let i = 0; i < 27; i++)\n   152→        await createTask(i + 1, 'owner/repo-a', 'complete');\n   153→      for (let i = 0; i < 10; i++)\n   154→        await createTask(100 + i, 'owner/repo-a', 'failed');\n   155→      for (let i = 0; i < 5; i++)\n   156→        await createTask(200 + i, 'owner/repo-a', 'stopped');\n   157→      for (let i = 0; i < 3; i++)\n   158→        await createTask(300 + i, 'owner/repo-a', 'stuck');\n   159→      for (let i = 0; i < 23; i++)\n   160→        await createTask(400 + i, 'owner/repo-a', 'blocked');\n   161→      for (let i = 0; i < 15; i++)\n   162→        await createTask(500 + i, 'owner/repo-a', 'gated');\n   163→\n   164→      const summary = await service.computeSummary('30d');\n   165→\n   166→      // denominator = 27 + 10 + 5 + 3 = 45; successRate = 27/45 ≈ 0.60\n   167→      expect(summary.successRate).toBeCloseTo(27 / 45);\n   168→      expect(summary.inProgressCount).toBe(38);\n   169→    });\n   170→\n   171→    it('returns successRate 0 and correct inProgressCount when all tasks are blocked or gated', async () => {\n   172→      for (let i = 0; i < 10; i++)\n   173→        await createTask(i + 1, 'owner/repo-a', 'blocked');\n   174→      for (let i = 0; i < 5; i++)\n   175→        await createTask(100 + i, 'owner/repo-a', 'gated');\n   176→\n   177→      const summary = await service.computeSummary('30d');\n   178→\n   179→      expect(summary.successRate).toBe(0);\n   180→      expect(summary.inProgressCount).toBe(15);\n   181→      expect(summary.total).toBe(15);\n   182→    });\n   183→  });\n   184→\n   185→  describe('computeTimeseries()', () => {\n   186→    it('returns empty array when no tasks in range', async () => {\n   187→      const result = await service.computeTimeseries('24h', 'hour');\n   188→      expect(Array.isArray(result)).toBe(true);\n   189→    });\n   190→\n   191→    it('returns TimeseriesPoint array with bucket and count', async () => {\n   192→      await createTask(1, 'owner/repo-a');\n   193→\n   194→      const result = await service.computeTimeseries('24h', 'hour');\n   195→\n   196→      if (result.length > 0) {\n   197→        expect(result[0]).toHaveProperty('bucket');\n   198→        expect(result[0]).toHaveProperty('count');\n   199→        expect(typeof result[0].bucket).toBe('string');\n   200→        expect(typeof result[0].count).toBe('number');\n   201→      }\n   202→    });\n   203→\n   204→    it('passes correct strftime format for 24h/hour (ISO hour bucket)', async () => {\n   205→      await createTask(1, 'owner/repo-a');\n   206→\n   207→      const result = await service.computeTimeseries('24h', 'hour');\n   208→\n   209→      if (result.length > 0) {\n   210→        // Format should be YYYY-MM-DDTHH:00:00\n   211→        expect(result[0].bucket).toMatch(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:00:00$/);\n   212→      }\n   213→    });\n   214→\n   215→    it('passes correct strftime format for 7d/day (date bucket)', async () => {\n   216→      await createTask(1, 'owner/repo-a');\n   217→\n   218→      const result = await service.computeTimeseries('7d', 'day');\n   219→\n   220→      if (result.length > 0) {\n   221→        // Format should be YYYY-MM-DD\n   222→        expect(result[0].bucket).toMatch(/^\\d{4}-\\d{2}-\\d{2}$/);\n   223→      }\n   224→    });\n   225→\n   226→    it('passes correct strftime format for 30d/day (date bucket)', async () => {\n   227→      await createTask(1, 'owner/repo-a');\n   228→\n   229→      const result = await service.computeTimeseries('30d', 'day');\n   230→\n   231→      if (result.length > 0) {\n   232→        expect(result[0].bucket).toMatch(/^\\d{4}-\\d{2}-\\d{2}$/);\n   233→      }\n   234→    });\n   235→  });\n   236→\n   237→  describe('computePhaseBreakdown()', () => {\n   238→    it('returns empty array when no phase events', async () => {\n   239→      const result = await service.computePhaseBreakdown('30d');\n   240→      expect(result).toEqual([]);\n   241→    });\n   242→\n   243→    it('pairs phase_started and phase_completed events correctly', async () => {\n   244→      const task = await createTask(1, 'owner/repo-a');\n   245→\n   246→      const startTime = new Date(Date.now() - 60_000); // 60s ago\n   247→      const endTime = new Date(); // now\n   248→\n   249→      await dataSource.query(\n   250→        `INSERT INTO events (task_id, type, source, data, created_at) VALUES (?, ?, ?, ?, ?)`,\n   251→        [\n   252→          task.id,\n   253→          'phase_started',\n   254→          'system',\n   255→          JSON.stringify({ phase: 'refine' }),\n   256→          startTime.toISOString(),\n   257→        ],\n   258→      );\n   259→      await dataSource.query(\n   260→        `INSERT INTO events (task_id, type, source, data, created_at) VALUES (?, ?, ?, ?, ?)`,\n   261→        [\n   262→          task.id,\n   263→          'refine_completed',\n   264→          'system',\n   265→          JSON.stringify({ phase: 'refine' }),\n   266→          endTime.toISOString(),\n   267→        ],\n   268→      );\n   269→\n   270→      const result = await service.computePhaseBreakdown('30d');\n   271→\n   272→      expect(result).toHaveLength(1);\n   273→      expect(result[0].phase).toBe('refine');\n   274→      expect(result[0].count).toBe(1);\n   275→      expect(result[0].avgDurationMs).toBeCloseTo(60_000, -1);\n   276→    });\n   277→\n   278→    it('returns PhaseBreakdown shape with phase, avgDurationMs, count', async () => {\n   279→      const task = await createTask(1, 'owner/repo-a');\n   280→\n   281→      await dataSource.query(\n   282→        `INSERT INTO events (task_id, type, source, data, created_at) VALUES (?, ?, ?, ?, ?)`,\n   283→        [\n   284→          task.id,\n   285→          'phase_started',\n   286→          'system',\n   287→          JSON.stringify({ phase: 'plan' }),\n   288→          new Date(Date.now() - 120_000).toISOString(),\n   289→        ],\n   290→      );\n   291→      await dataSource.query(\n   292→        `INSERT INTO events (task_id, type, source, data, created_at) VALUES (?, ?, ?, ?, ?)`,\n   293→        [\n   294→          task.id,\n   295→          'plan_completed',\n   296→          'system',\n   297→          JSON.stringify({ phase: 'plan' }),\n   298→          new Date().toISOString(),\n   299→        ],\n   300→      );\n   301→\n   302→      const result = await service.computePhaseBreakdown('30d');\n   303→\n   304→      expect(result.length).toBeGreaterThanOrEqual(1);\n   305→      const planBreakdown = result.find((r) => r.phase === 'plan');\n   306→      expect(planBreakdown).toBeDefined();\n   307→      expect(planBreakdown).toHaveProperty('phase');\n   308→      expect(planBreakdown).toHaveProperty('avgDurationMs');\n   309→      expect(planBreakdown).toHaveProperty('count');\n   310→    });\n   311→\n   312→    it('ignores phase_started events without a matching phase_completed', async () => {\n   313→      const task = await createTask(1, 'owner/repo-a');\n   314→\n   315→      await dataSource.query(\n   316→        `INSERT INTO events (task_id, type, source, data, created_at) VALUES (?, ?, ?, ?, ?)`,\n   317→        [\n   318→          task.id,\n   319→          'phase_started',\n   320→          'system',\n   321→          JSON.stringify({ phase: 'execute' }),\n   322→          '2026-01-01T00:00:00.000Z',\n   323→        ],\n   324→      );\n   325→\n   326→      const result = await service.computePhaseBreakdown('30d');\n   327→\n   328→      expect(result.find((r) => r.phase === 'execute')).toBeUndefined();\n   329→    });\n   330→  });\n   331→});\n   332→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"5f092670-c515-4db2-a5c1-41b25ec8804f","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/metrics/metrics.service.spec.ts","content":"import { Test, TestingModule } from '@nestjs/testing';\nimport { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm';\nimport { Repository, DataSource } from 'typeorm';\nimport { TaskEntity, EventEntity } from '../database';\nimport { MetricsService } from './metrics.service';\nimport { ExcludedReposService } from '../config/excluded-repos.service';\n\ndescribe('MetricsService', () => {\n  let module: TestingModule;\n  let service: MetricsService;\n  let taskRepo: Repository<TaskEntity>;\n  let dataSource: DataSource;\n\n  beforeAll(async () => {\n    module = await Test.createTestingModule({\n      imports: [\n        TypeOrmModule.forRoot({\n          type: 'better-sqlite3',\n          database: ':memory:',\n          entities: [TaskEntity, EventEntity],\n          synchronize: true,\n        }),\n        TypeOrmModule.forFeature([TaskEntity, EventEntity]),\n      ],\n      providers: [\n        MetricsService,\n        {\n          provide: ExcludedReposService,\n          useValue: {\n            getList: (): string[] => ['AgentSDE/agent-core-e2e'],\n            isExcluded: (repo: string): boolean =>\n              repo === 'AgentSDE/agent-core-e2e',\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get<MetricsService>(MetricsService);\n    taskRepo = module.get<Repository<TaskEntity>>(\n      getRepositoryToken(TaskEntity),\n    );\n    dataSource = module.get<DataSource>(DataSource);\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  beforeEach(async () => {\n    await dataSource.query('DELETE FROM events');\n    await dataSource.query('DELETE FROM tasks');\n  });\n\n  async function createTask(\n    issue: number,\n    repo: string,\n    status = 'active',\n    startedAt: Date | null = null,\n    completedAt: Date | null = null,\n  ): Promise<TaskEntity> {\n    const task = taskRepo.create({ issue, repo, status });\n    task.startedAt = startedAt;\n    task.completedAt = completedAt;\n    return taskRepo.save(task);\n  }\n\n  describe('computeSummary()', () => {\n    it('returns correct shape with zero tasks', async () => {\n      const summary = await service.computeSummary('30d');\n\n      expect(summary.total).toBe(0);\n      expect(summary.byStatus).toEqual({});\n      expect(summary.successRate).toBe(0);\n      expect(summary.activeCount).toBe(0);\n      expect(summary.inProgressCount).toBe(0);\n      expect(summary.avgCompletionMs).toBeNull();\n    });\n\n    it('counts total tasks correctly', async () => {\n      await createTask(1, 'owner/repo-a');\n      await createTask(2, 'owner/repo-b');\n      await createTask(3, 'owner/repo-c');\n\n      const summary = await service.computeSummary('30d');\n\n      expect(summary.total).toBe(3);\n    });\n\n    it('groups byStatus correctly', async () => {\n      await createTask(1, 'owner/repo-a', 'active');\n      await createTask(2, 'owner/repo-b', 'active');\n      await createTask(3, 'owner/repo-c', 'completed');\n      await createTask(4, 'owner/repo-d', 'failed');\n\n      const summary = await service.computeSummary('30d');\n\n      expect(summary.byStatus['active']).toBe(2);\n      expect(summary.byStatus['completed']).toBe(1);\n      expect(summary.byStatus['failed']).toBe(1);\n    });\n\n    it('computes successRate as completed / (completed + failed)', async () => {\n      await createTask(1, 'owner/repo-a', 'complete');\n      await createTask(2, 'owner/repo-b', 'complete');\n      await createTask(3, 'owner/repo-c', 'failed');\n\n      const summary = await service.computeSummary('30d');\n\n      expect(summary.successRate).toBeCloseTo(2 / 3);\n    });\n\n    it('returns successRate 0 when no completed or failed tasks', async () => {\n      await createTask(1, 'owner/repo-a', 'active');\n\n      const summary = await service.computeSummary('30d');\n\n      expect(summary.successRate).toBe(0);\n    });\n\n    it('returns activeCount for active tasks', async () => {\n      await createTask(1, 'owner/repo-a', 'active');\n      await createTask(2, 'owner/repo-b', 'active');\n      await createTask(3, 'owner/repo-c', 'completed');\n\n      const summary = await service.computeSummary('30d');\n\n      expect(summary.activeCount).toBe(2);\n    });\n\n    it('computes avgCompletionMs when startedAt/completedAt are set', async () => {\n      const start = new Date('2026-01-01T00:00:00.000Z');\n      const end = new Date('2026-01-01T00:01:00.000Z'); // 60_000 ms later\n\n      await createTask(1, 'owner/repo-a', 'completed', start, end);\n\n      const summary = await service.computeSummary('30d');\n\n      expect(summary.avgCompletionMs).not.toBeNull();\n      expect(summary.avgCompletionMs).toBeCloseTo(60_000, -1);\n    });\n\n    it('returns avgCompletionMs null when no tasks with both dates', async () => {\n      await createTask(1, 'owner/repo-a', 'active');\n\n      const summary = await service.computeSummary('30d');\n\n      expect(summary.avgCompletionMs).toBeNull();\n    });\n\n    it('computes successRate and inProgressCount for issue scenario (27 complete, 10 failed, 5 stopped, 3 stuck, 23 blocked, 15 gated)', async () => {\n      for (let i = 0; i < 27; i++)\n        await createTask(i + 1, 'owner/repo-a', 'complete');\n      for (let i = 0; i < 10; i++)\n        await createTask(100 + i, 'owner/repo-a', 'failed');\n      for (let i = 0; i < 5; i++)\n        await createTask(200 + i, 'owner/repo-a', 'stopped');\n      for (let i = 0; i < 3; i++)\n        await createTask(300 + i, 'owner/repo-a', 'stuck');\n      for (let i = 0; i < 23; i++)\n        await createTask(400 + i, 'owner/repo-a', 'blocked');\n      for (let i = 0; i < 15; i++)\n        await createTask(500 + i, 'owner/repo-a', 'gated');\n\n      const summary = await service.computeSummary('30d');\n\n      // denominator = 27 + 10 + 5 + 3 = 45; successRate = 27/45 ≈ 0.60\n      expect(summary.successRate).toBeCloseTo(27 / 45);\n      expect(summary.inProgressCount).toBe(38);\n    });\n\n    it('returns successRate 0 and correct inProgressCount when all tasks are blocked or gated', async () => {\n      for (let i = 0; i < 10; i++)\n        await createTask(i + 1, 'owner/repo-a', 'blocked');\n      for (let i = 0; i < 5; i++)\n        await createTask(100 + i, 'owner/repo-a', 'gated');\n\n      const summary = await service.computeSummary('30d');\n\n      expect(summary.successRate).toBe(0);\n      expect(summary.inProgressCount).toBe(15);\n      expect(summary.total).toBe(15);\n    });\n  });\n\n  describe('computeTimeseries()', () => {\n    it('returns empty array when no tasks in range', async () => {\n      const result = await service.computeTimeseries('24h', 'hour');\n      expect(Array.isArray(result)).toBe(true);\n    });\n\n    it('returns TimeseriesPoint array with bucket and count', async () => {\n      await createTask(1, 'owner/repo-a');\n\n      const result = await service.computeTimeseries('24h', 'hour');\n\n      if (result.length > 0) {\n        expect(result[0]).toHaveProperty('bucket');\n        expect(result[0]).toHaveProperty('count');\n        expect(typeof result[0].bucket).toBe('string');\n        expect(typeof result[0].count).toBe('number');\n      }\n    });\n\n    it('passes correct strftime format for 24h/hour (ISO hour bucket)', async () => {\n      await createTask(1, 'owner/repo-a');\n\n      const result = await service.computeTimeseries('24h', 'hour');\n\n      if (result.length > 0) {\n        // Format should be YYYY-MM-DDTHH:00:00\n        expect(result[0].bucket).toMatch(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:00:00$/);\n      }\n    });\n\n    it('passes correct strftime format for 7d/day (date bucket)', async () => {\n      await createTask(1, 'owner/repo-a');\n\n      const result = await service.computeTimeseries('7d', 'day');\n\n      if (result.length > 0) {\n        // Format should be YYYY-MM-DD\n        expect(result[0].bucket).toMatch(/^\\d{4}-\\d{2}-\\d{2}$/);\n      }\n    });\n\n    it('passes correct strftime format for 30d/day (date bucket)', async () => {\n      await createTask(1, 'owner/repo-a');\n\n      const result = await service.computeTimeseries('30d', 'day');\n\n      if (result.length > 0) {\n        expect(result[0].bucket).toMatch(/^\\d{4}-\\d{2}-\\d{2}$/);\n      }\n    });\n  });\n\n  describe('computePhaseBreakdown()', () => {\n    it('returns empty array when no phase events', async () => {\n      const result = await service.computePhaseBreakdown('30d');\n      expect(result).toEqual([]);\n    });\n\n    it('pairs phase_started and phase_completed events correctly', async () => {\n      const task = await createTask(1, 'owner/repo-a');\n\n      const startTime = new Date(Date.now() - 60_000); // 60s ago\n      const endTime = new Date(); // now\n\n      await dataSource.query(\n        `INSERT INTO events (task_id, type, source, data, created_at) VALUES (?, ?, ?, ?, ?)`,\n        [\n          task.id,\n          'phase_started',\n          'system',\n          JSON.stringify({ phase: 'refine' }),\n          startTime.toISOString(),\n        ],\n      );\n      await dataSource.query(\n        `INSERT INTO events (task_id, type, source, data, created_at) VALUES (?, ?, ?, ?, ?)`,\n        [\n          task.id,\n          'refine_completed',\n          'system',\n          JSON.stringify({ phase: 'refine' }),\n          endTime.toISOString(),\n        ],\n      );\n\n      const result = await service.computePhaseBreakdown('30d');\n\n      expect(result).toHaveLength(1);\n      expect(result[0].phase).toBe('refine');\n      expect(result[0].count).toBe(1);\n      expect(result[0].avgDurationMs).toBeCloseTo(60_000, -1);\n    });\n\n    it('returns PhaseBreakdown shape with phase, avgDurationMs, count', async () => {\n      const task = await createTask(1, 'owner/repo-a');\n\n      await dataSource.query(\n        `INSERT INTO events (task_id, type, source, data, created_at) VALUES (?, ?, ?, ?, ?)`,\n        [\n          task.id,\n          'phase_started',\n          'system',\n          JSON.stringify({ phase: 'plan' }),\n          new Date(Date.now() - 120_000).toISOString(),\n        ],\n      );\n      await dataSource.query(\n        `INSERT INTO events (task_id, type, source, data, created_at) VALUES (?, ?, ?, ?, ?)`,\n        [\n          task.id,\n          'plan_completed',\n          'system',\n          JSON.stringify({ phase: 'plan' }),\n          new Date().toISOString(),\n        ],\n      );\n\n      const result = await service.computePhaseBreakdown('30d');\n\n      expect(result.length).toBeGreaterThanOrEqual(1);\n      const planBreakdown = result.find((r) => r.phase === 'plan');\n      expect(planBreakdown).toBeDefined();\n      expect(planBreakdown).toHaveProperty('phase');\n      expect(planBreakdown).toHaveProperty('avgDurationMs');\n      expect(planBreakdown).toHaveProperty('count');\n    });\n\n    it('ignores phase_started events without a matching phase_completed', async () => {\n      const task = await createTask(1, 'owner/repo-a');\n\n      await dataSource.query(\n        `INSERT INTO events (task_id, type, source, data, created_at) VALUES (?, ?, ?, ?, ?)`,\n        [\n          task.id,\n          'phase_started',\n          'system',\n          JSON.stringify({ phase: 'execute' }),\n          '2026-01-01T00:00:00.000Z',\n        ],\n      );\n\n      const result = await service.computePhaseBreakdown('30d');\n\n      expect(result.find((r) => r.phase === 'execute')).toBeUndefined();\n    });\n  });\n});\n","numLines":332,"startLine":1,"totalLines":332}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01FSQKizJmVCRBWNNGi5Qa3p","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I need to read the phase-router spec file in chunks since it's large:","signature":"EoYCClsIDBgCKkBJnRba9usjj+HXKr3EIvi2XiQq4Zb8bBrLyFStmxbHMCbRNSg4toGs+PNsUd7LyANutjd89d3Jdp/Rbbf2BdTCMhFjbGF1ZGUtc29ubmV0LTQtNjgAEgxiLIpt5HAMu5anjsUaDFXQOm4r2MCN5qUfTiIwmiDTXggs/4VrEaM5QdftsbMbnJgwx6Q18mnpWFFHSFWMZFR70He2hC5wC7tj4m99Klmw8zTfbwxS27iSU8o/OUR7OjsTZJMxyd14zN1auGkoEbfDJIUnXw1s70cNQkcIuxmmW8MfsTj/J6cNwwhA7/Ez6yR0Q8ReTTcKy4gemJKqnQaiNx7q2u93aBgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":26461,"cache_read_input_tokens":51825,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":26461},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"63040498-a042-4b86-a358-9fc942601413"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01FSQKizJmVCRBWNNGi5Qa3p","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me read the phase-router spec in parts:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":26461,"cache_read_input_tokens":51825,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":26461},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"56011534-495f-4e2c-b099-3c7c383c2055"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01FSQKizJmVCRBWNNGi5Qa3p","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PALWm2c31WdMxkHdZQBtbw","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","offset":1,"limit":200},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":26461,"cache_read_input_tokens":51825,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":26461},"output_tokens":9,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"b80bf864-1dcc-41a9-900a-e8d8ed7d8d9c"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PALWm2c31WdMxkHdZQBtbw","type":"tool_result","content":"     1→/* eslint-disable @typescript-eslint/unbound-method */\n     2→\n     3→import { Test, TestingModule } from '@nestjs/testing';\n     4→import { PhaseRouterService } from './phase-router.service';\n     5→import { TaskStateService } from '../task-state/task-state.service';\n     6→import { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\n     7→import { ClaudeInvocationService } from '../invoke/claude-invocation.service';\n     8→import { WorktreeService } from '../worktree/worktree.service';\n     9→import { ArtefactsService } from '../artefacts/artefacts.service';\n    10→import { PhaseHooksService } from '../hooks/phase-hooks.service';\n    11→import { EventService } from '../event/event.service';\n    12→import { GitHubService } from '../github/github.service';\n    13→import { TokenUsageService } from '../token-usage/token-usage.service';\n    14→import { ReleaseService } from '../release/release.service';\n    15→import { EventBusService } from '../event-bus';\n    16→import type { TaskEntity } from '../database/entities/task.entity';\n    17→import type { DispatchEvent } from '../queue/job-queue.interface';\n    18→\n    19→function makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n    20→  return {\n    21→    id: 1,\n    22→    issue: 42,\n    23→    repo: 'org/repo',\n    24→    directive: 'auto',\n    25→    currentPhase: 'refine',\n    26→    status: 'active',\n    27→    gatePhase: null,\n    28→    gateReason: null,\n    29→    prNumber: null,\n    30→    prBranch: null,\n    31→    phaseRefine: 'pending',\n    32→    phasePlan: 'pending',\n    33→    phaseExecute: 'pending',\n    34→    phaseReview: 'pending',\n    35→    phaseDeliver: 'pending',\n    36→    phaseCompound: 'pending',\n    37→    qualityGateOverride: false,\n    38→    createdAt: new Date(),\n    39→    updatedAt: new Date(),\n    40→    ...overrides,\n    41→  } as TaskEntity;\n    42→}\n    43→\n    44→function makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n    45→  return {\n    46→    type: 'issue_assigned',\n    47→    issueNumber: 42,\n    48→    repo: 'org/repo',\n    49→    ...overrides,\n    50→  };\n    51→}\n    52→\n    53→describe('PhaseRouterService', () => {\n    54→  let service: PhaseRouterService;\n    55→  let taskState: jest.Mocked<TaskStateService>;\n    56→  let internalAdapter: jest.Mocked<InternalAdapterService>;\n    57→  let claude: jest.Mocked<ClaudeInvocationService>;\n    58→  let worktreeService: jest.Mocked<WorktreeService>;\n    59→  let artefacts: jest.Mocked<ArtefactsService>;\n    60→  let hooks: jest.Mocked<PhaseHooksService>;\n    61→  let eventService: jest.Mocked<EventService>;\n    62→  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n    63→  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n    64→  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n    65→  let releaseService: jest.Mocked<\n    66→    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n    67→  >;\n    68→\n    69→  beforeEach(async () => {\n    70→    const module: TestingModule = await Test.createTestingModule({\n    71→      providers: [\n    72→        PhaseRouterService,\n    73→        {\n    74→          provide: TaskStateService,\n    75→          useValue: {\n    76→            findByIssueAndRepo: jest.fn(),\n    77→            createTask: jest.fn(),\n    78→            save: jest.fn(),\n    79→            getPhaseStatus: jest.fn(),\n    80→            setPhaseStatus: jest.fn(),\n    81→            setGate: jest.fn(),\n    82→            clearGate: jest.fn(),\n    83→            resetForRetry: jest.fn(),\n    84→            resetForRestart: jest.fn(),\n    85→            setQualityGateOverride: jest.fn(),\n    86→            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n    87→          },\n    88→        },\n    89→        {\n    90→          provide: InternalAdapterService,\n    91→          useValue: {\n    92→            handleSignal: jest.fn(),\n    93→            getNextPhase: jest.fn(),\n    94→          },\n    95→        },\n    96→        {\n    97→          provide: ClaudeInvocationService,\n    98→          useValue: {\n    99→            invoke: jest.fn(),\n   100→          },\n   101→        },\n   102→        {\n   103→          provide: WorktreeService,\n   104→          useValue: {\n   105→            getOrCreateWorktree: jest.fn(),\n   106→            cleanupWorktree: jest.fn(),\n   107→          },\n   108→        },\n   109→        {\n   110→          provide: ArtefactsService,\n   111→          useValue: {\n   112→            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n   113→            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n   114→            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n   115→            buildCentralTaskDir: jest\n   116→              .fn()\n   117→              .mockReturnValue(\n   118→                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n   119→              ),\n   120→          },\n   121→        },\n   122→        {\n   123→          provide: PhaseHooksService,\n   124→          useValue: {\n   125→            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n   126→            onPhaseComplete: jest.fn(),\n   127→            onPhaseSkip: jest.fn(),\n   128→          },\n   129→        },\n   130→        {\n   131→          provide: EventService,\n   132→          useValue: {\n   133→            record: jest.fn(),\n   134→            countByType: jest.fn().mockResolvedValue(0),\n   135→          },\n   136→        },\n   137→        {\n   138→          provide: GitHubService,\n   139→          useValue: {\n   140→            postComment: jest.fn().mockResolvedValue(undefined),\n   141→            getPRFiles: jest.fn().mockResolvedValue([]),\n   142→          },\n   143→        },\n   144→        {\n   145→          provide: EventBusService,\n   146→          useValue: {\n   147→            emit: jest.fn(),\n   148→            on: jest.fn(),\n   149→          },\n   150→        },\n   151→        {\n   152→          provide: TokenUsageService,\n   153→          useValue: {\n   154→            record: jest.fn().mockResolvedValue(undefined),\n   155→          },\n   156→        },\n   157→        {\n   158→          provide: ReleaseService,\n   159→          useValue: {\n   160→            findActiveForTask: jest.fn().mockResolvedValue(null),\n   161→            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n   162→          },\n   163→        },\n   164→      ],\n   165→    }).compile();\n   166→\n   167→    service = module.get(PhaseRouterService);\n   168→    taskState = module.get(TaskStateService);\n   169→    internalAdapter = module.get(InternalAdapterService);\n   170→    claude = module.get(ClaudeInvocationService);\n   171→    worktreeService = module.get(WorktreeService);\n   172→    artefacts = module.get(ArtefactsService);\n   173→    hooks = module.get(PhaseHooksService);\n   174→    eventService = module.get(EventService);\n   175→    gitHub = module.get(GitHubService);\n   176→    eventBus = module.get(EventBusService);\n   177→    releaseService = module.get(ReleaseService);\n   178→  });\n   179→\n   180→  describe('route()', () => {\n   181→    it('should create a new task when none exists and execute the refine phase', async () => {\n   182→      const task = makeTask();\n   183→      taskState.findByIssueAndRepo.mockResolvedValue(null);\n   184→      taskState.createTask.mockResolvedValue(task);\n   185→      taskState.save.mockResolvedValue(task);\n   186→      claude.invoke.mockResolvedValue(undefined);\n   187→\n   188→      await service.route(makeEvent());\n   189→\n   190→      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n   191→      expect(claude.invoke).toHaveBeenCalledWith(\n   192→        'refine',\n   193→        expect.objectContaining({\n   194→          ISSUE_NUMBER: '42',\n   195→          REPO: 'org/repo',\n   196→          PHASE: 'refine',\n   197→        }),\n   198→        task.id,\n   199→      );\n   200→    });\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"d343a673-b3e5-4525-89ac-6e6f5e5bced8","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","content":"/* eslint-disable @typescript-eslint/unbound-method */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { PhaseRouterService } from './phase-router.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\nimport { ClaudeInvocationService } from '../invoke/claude-invocation.service';\nimport { WorktreeService } from '../worktree/worktree.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventService } from '../event/event.service';\nimport { GitHubService } from '../github/github.service';\nimport { TokenUsageService } from '../token-usage/token-usage.service';\nimport { ReleaseService } from '../release/release.service';\nimport { EventBusService } from '../event-bus';\nimport type { TaskEntity } from '../database/entities/task.entity';\nimport type { DispatchEvent } from '../queue/job-queue.interface';\n\nfunction makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n  return {\n    id: 1,\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    currentPhase: 'refine',\n    status: 'active',\n    gatePhase: null,\n    gateReason: null,\n    prNumber: null,\n    prBranch: null,\n    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseExecute: 'pending',\n    phaseReview: 'pending',\n    phaseDeliver: 'pending',\n    phaseCompound: 'pending',\n    qualityGateOverride: false,\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    ...overrides,\n  } as TaskEntity;\n}\n\nfunction makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n  return {\n    type: 'issue_assigned',\n    issueNumber: 42,\n    repo: 'org/repo',\n    ...overrides,\n  };\n}\n\ndescribe('PhaseRouterService', () => {\n  let service: PhaseRouterService;\n  let taskState: jest.Mocked<TaskStateService>;\n  let internalAdapter: jest.Mocked<InternalAdapterService>;\n  let claude: jest.Mocked<ClaudeInvocationService>;\n  let worktreeService: jest.Mocked<WorktreeService>;\n  let artefacts: jest.Mocked<ArtefactsService>;\n  let hooks: jest.Mocked<PhaseHooksService>;\n  let eventService: jest.Mocked<EventService>;\n  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let releaseService: jest.Mocked<\n    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n  >;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        PhaseRouterService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByIssueAndRepo: jest.fn(),\n            createTask: jest.fn(),\n            save: jest.fn(),\n            getPhaseStatus: jest.fn(),\n            setPhaseStatus: jest.fn(),\n            setGate: jest.fn(),\n            clearGate: jest.fn(),\n            resetForRetry: jest.fn(),\n            resetForRestart: jest.fn(),\n            setQualityGateOverride: jest.fn(),\n            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: InternalAdapterService,\n          useValue: {\n            handleSignal: jest.fn(),\n            getNextPhase: jest.fn(),\n          },\n        },\n        {\n          provide: ClaudeInvocationService,\n          useValue: {\n            invoke: jest.fn(),\n          },\n        },\n        {\n          provide: WorktreeService,\n          useValue: {\n            getOrCreateWorktree: jest.fn(),\n            cleanupWorktree: jest.fn(),\n          },\n        },\n        {\n          provide: ArtefactsService,\n          useValue: {\n            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n            buildCentralTaskDir: jest\n              .fn()\n              .mockReturnValue(\n                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n              ),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n            onPhaseComplete: jest.fn(),\n            onPhaseSkip: jest.fn(),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn(),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: GitHubService,\n          useValue: {\n            postComment: jest.fn().mockResolvedValue(undefined),\n            getPRFiles: jest.fn().mockResolvedValue([]),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: TokenUsageService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get(PhaseRouterService);\n    taskState = module.get(TaskStateService);\n    internalAdapter = module.get(InternalAdapterService);\n    claude = module.get(ClaudeInvocationService);\n    worktreeService = module.get(WorktreeService);\n    artefacts = module.get(ArtefactsService);\n    hooks = module.get(PhaseHooksService);\n    eventService = module.get(EventService);\n    gitHub = module.get(GitHubService);\n    eventBus = module.get(EventBusService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  describe('route()', () => {\n    it('should create a new task when none exists and execute the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({\n          ISSUE_NUMBER: '42',\n          REPO: 'org/repo',\n          PHASE: 'refine',\n        }),\n        task.id,\n      );\n    });","numLines":200,"startLine":1,"totalLines":1607}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_019eSfhBYkv4aas48ccWwmrF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01A3cuRYrN3NTEaEAKpeamfP","name":"Grep","input":{"pattern":"execute|phaseExecute","path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","output_mode":"content","-n":true},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2902,"cache_read_input_tokens":78286,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2902},"output_tokens":50,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"4633507b-bcab-4f10-b8b1-12639dc8c2c2"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01A3cuRYrN3NTEaEAKpeamfP","type":"tool_result","content":"33:    phaseExecute: 'pending',\n181:    it('should create a new task when none exists and execute the refine phase', async () => {\n230:    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n294:    it('should execute phase when phase status is pending (first run)', async () => {\n356:      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n397:      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n434:      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n451:      const task = makeTask({ status: 'cancelled', currentPhase: 'execute' });\n475:    it('should clear gate and execute phase on directive event', async () => {\n510:  describe('executePhase() via route()', () => {\n627:    it('should not create a worktree for non-execute phases', async () => {\n750:        service.route(makeEvent({ type: 'phase_execute' })),\n763:      await service.route(makeEvent({ type: 'phase_execute' }));\n807:        service.resolvePhase(makeEvent({ type: 'phase_execute' }), task),\n808:      ).toBe('execute');\n941:        currentPhase: 'execute',\n948:      ).toBe('execute');\n967:        currentPhase: 'execute',\n988:      const task = makeTask({ status: 'blocked', currentPhase: 'execute' });\n1186:    it('retry on stuck task: resets state and executes current phase', async () => {\n1189:        currentPhase: 'execute',\n1190:        phaseExecute: 'active',\n1205:        'execute',\n1206:        expect.objectContaining({ PHASE: 'execute' }),\n1211:    it('retry on blocked task: resets state and executes', async () => {\n1255:      const task = makeTask({ status: 'failed', currentPhase: 'execute' });\n1267:    it('restart on non-active task: resets all phases and executes refine', async () => {\n1270:        currentPhase: 'execute',\n1291:      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n1402:    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n1404:        currentPhase: 'execute',\n1406:        phaseExecute: 'active',\n1443:        currentPhase: 'execute',\n1576:    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n1577:      // Phase status is 'active' in-memory on the task passed into executePhase.\n1583:      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n1595:      // executePhase() defensive guard also returns 'pending' → proceed to invoke"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"e2d604b3-1396-401e-98c5-09c753260d54","tool_use_result":{"mode":"content","numFiles":0,"filenames":[],"content":"33:    phaseExecute: 'pending',\n181:    it('should create a new task when none exists and execute the refine phase', async () => {\n230:    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n294:    it('should execute phase when phase status is pending (first run)', async () => {\n356:      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n397:      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n434:      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n451:      const task = makeTask({ status: 'cancelled', currentPhase: 'execute' });\n475:    it('should clear gate and execute phase on directive event', async () => {\n510:  describe('executePhase() via route()', () => {\n627:    it('should not create a worktree for non-execute phases', async () => {\n750:        service.route(makeEvent({ type: 'phase_execute' })),\n763:      await service.route(makeEvent({ type: 'phase_execute' }));\n807:        service.resolvePhase(makeEvent({ type: 'phase_execute' }), task),\n808:      ).toBe('execute');\n941:        currentPhase: 'execute',\n948:      ).toBe('execute');\n967:        currentPhase: 'execute',\n988:      const task = makeTask({ status: 'blocked', currentPhase: 'execute' });\n1186:    it('retry on stuck task: resets state and executes current phase', async () => {\n1189:        currentPhase: 'execute',\n1190:        phaseExecute: 'active',\n1205:        'execute',\n1206:        expect.objectContaining({ PHASE: 'execute' }),\n1211:    it('retry on blocked task: resets state and executes', async () => {\n1255:      const task = makeTask({ status: 'failed', currentPhase: 'execute' });\n1267:    it('restart on non-active task: resets all phases and executes refine', async () => {\n1270:        currentPhase: 'execute',\n1291:      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n1402:    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n1404:        currentPhase: 'execute',\n1406:        phaseExecute: 'active',\n1443:        currentPhase: 'execute',\n1576:    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n1577:      // Phase status is 'active' in-memory on the task passed into executePhase.\n1583:      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n1595:      // executePhase() defensive guard also returns 'pending' → proceed to invoke","numLines":37}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01RzicwTwyqyKFNzrkG2CZfe","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Let me now look at the specific lines around the execute references in the phase-router spec file that need to be changed. The key ones are:\n1. Line 33: `phaseExecute: 'pending'` in `makeTask()`\n2. Line 356, 397, 434, 451: `currentPhase: 'execute'`\n3. Line 750, 763: `phase_execute` event type\n4. Line 807-808: `phase_execute` and `.toBe('execute')`\n5. Line 941, 948: `currentPhase: 'execute'`, `.toBe('execute')`\n\n\n6. Line 967, 988: `currentPhase: 'execute'`\n7. Line 1189-1190: `currentPhase: 'execute'`, `phaseExecute: 'active'`\n8. Line 1205-1206: `'execute'`, `{ PHASE: 'execute' }`\n9. Line 1270: `currentPhase: 'execute'`\n10. Line 1291: `currentPhase: 'execute'`\n11. Line 1402-1406: `currentPhase: 'execute'`, `phaseExecute: 'active'`\n12. Line 1443: `currentPhase: 'execute'`\n\nI need to scan through more of the file to get a better sense of the overall pattern and context for these changes.","signature":"EvkHClsIDBgCKkAUYPjk9+mr87+aus4QUT1R8mn04ee/F8DbIk1LYx8Mil4rr6acO+1DFjUatoWSfbdMIdQ00pLr0AM8UlCcDIEYMhFjbGF1ZGUtc29ubmV0LTQtNjgAEgw2OBlRpkQi8B/SxY4aDPY29Bx8fnB1gEIKdyIwhF29zBq1AQXbLzzzxlPzZO206eX9jia9k/GiqOtMD9pOApkPes75YyAIY6BcG1Q8KssGTtI5mTpyhScN6/EdesKu0AY3sDE9fY08u+5udOKsT82E0lwN+ZQSt8ct0nZCDKQSSBD3B8NaD/T7f0YMW8Xm496AVRCdWQRONHHXWBODNajSBT207NEKdy72wduD/w6MSmdnF/JxCdQsqVwUGkSlIZfUWKfBusfVmbwvOMcjVenhtvpoTbo9rBdqJjgCiy4Plbm9HdxSnNqcZ9xCSeicn16jG+yJe4eMjYQQzBgGYfEJP7f3mbmHmME0B8Hh5o7rVBzQpuOL7TBNth24KySqkttsXU20P99rXUmTyRad+lfT9TzrGgdtViReGcUVxA3JNDCWu+skZlyl5lGvgwU6HAo7DV3JgjEokkqZKIbjz+1CZ3kl73lBHSjD4vpbH3Rnnwufu8QJrsiIyfmKSS06B2/AqiGI3/gDf9Cpe2ah8A+h1kiNe/0wZ8BSX5QVS2QQ2KtjgGICjRSLT/sbxw1E/geNl3GTBR3u4YwJG6uENX+BBlrj4xQlcwnqzxI29PtqE0YKl1v/XwdIPFYkCZGHCMS7WQADT55/JaLw9gH42ZQzo2n60WZZzbkWu7lcU8G144B+lOekVYde3mYABfOYGg1521i4jgirSW5rI1FDxkCAO84nTPxueNeZXAjY/5XLg4Z+6qGB9ZnEYtZW2egHulqf6EbhQCbtWWy8dtBQkcHdAEEDrCVCegQRafmx4NXSXNYb3kzccmVALj2mpPcR4s8oIfLdmmB/g53KQdKIhUx25vFzp495jDI3Z90vawR7kFsgDZ00/vN8t5/jYeKyUeKDmuXmLAdrlhXv8URPTwJH3JPORVFp5uJwXX0fqm6OMI35fejHnAtZOOdoMJgAATcFjg38fssRYQoI+Z0U19uiu7+uL1Vmpbql22anlO+eM2GEm144Iojr+Lj25Um3A/i6sBs2k4hzBl1Gq8RUdpUwZUW51ebqBIMROAhr8Hrn0PLvYxD6P5q39L+DFtZoVD1ltOLZB+tL8NhK4JXhObNoGJqnuW2wYA4vJTXQiKcH8I8bWTei8CwIyxOc6xTURET57WKCpUa1iYTFzf3+QVnUpbZ8BIv5di5eV6Ejkjv8RBI/RTt++xhKIlWUp+SolxQVYd8BqoLIonSKGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":913,"cache_read_input_tokens":81188,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":913},"output_tokens":13,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"89239651-4af3-4006-857e-b1017ce969c3"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01RzicwTwyqyKFNzrkG2CZfe","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BxaxHgQ7kUxF1PDQLLbfsu","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","offset":340,"limit":130},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":913,"cache_read_input_tokens":81188,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":913},"output_tokens":13,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"2d6a59c1-8954-48c1-b242-a323c1fb23bd"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BxaxHgQ7kUxF1PDQLLbfsu","type":"tool_result","content":"   340→      // completion event was already in the queue.  The fresh DB read must win.\n   341→      const task = makeTask({\n   342→        currentPhase: 'plan',\n   343→        status: 'paused',\n   344→        phasePlan: 'pending',\n   345→      });\n   346→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n   347→      taskState.getPhaseStatus.mockReturnValue('pending');\n   348→      taskState.save.mockResolvedValue(task);\n   349→\n   350→      await service.route(makeEvent({ type: 'phase_plan' }));\n   351→\n   352→      expect(claude.invoke).not.toHaveBeenCalled();\n   353→    });\n   354→\n   355→    it('should pause task and return early when /agent stop directive arrives', async () => {\n   356→      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n   357→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n   358→      taskState.save.mockResolvedValue(task);\n   359→\n   360→      await service.route(\n   361→        makeEvent({\n   362→          type: 'directive',\n   363→          commentBody: '/agent stop',\n   364→          actor: 'zhfahan77',\n   365→        }),\n   366→      );\n   367→\n   368→      expect(taskState.save).toHaveBeenCalledWith(\n   369→        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n   370→      );\n   371→      expect(claude.invoke).not.toHaveBeenCalled();\n   372→      expect(eventService.record).toHaveBeenCalledWith(\n   373→        task.id,\n   374→        'task_paused',\n   375→        'phase-router',\n   376→        { actor: 'zhfahan77' },\n   377→      );\n   378→    });\n   379→\n   380→    it('should allow re-executing completed refine on issue_comment event', async () => {\n   381→      const task = makeTask({\n   382→        currentPhase: 'refine',\n   383→        status: 'active',\n   384→        phaseRefine: 'complete',\n   385→      });\n   386→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n   387→      taskState.getPhaseStatus.mockReturnValue('complete');\n   388→      taskState.save.mockResolvedValue(task);\n   389→      claude.invoke.mockResolvedValue(undefined);\n   390→\n   391→      await service.route(makeEvent({ type: 'issue_comment' }));\n   392→\n   393→      expect(claude.invoke).toHaveBeenCalled();\n   394→    });\n   395→\n   396→    it('should cancel task on task_cancelled event', async () => {\n   397→      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n   398→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n   399→      taskState.save.mockResolvedValue(task);\n   400→      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n   401→\n   402→      await service.route(\n   403→        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n   404→      );\n   405→\n   406→      expect(task.status).toBe('cancelled');\n   407→      expect(taskState.save).toHaveBeenCalled();\n   408→      expect(eventService.record).toHaveBeenCalledWith(\n   409→        task.id,\n   410→        'task_cancelled',\n   411→        'phase-router',\n   412→        expect.objectContaining({ actor: 'human-dev' }),\n   413→      );\n   414→      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n   415→        'org/repo',\n   416→        42,\n   417→        'feat',\n   418→      );\n   419→      expect(claude.invoke).not.toHaveBeenCalled();\n   420→    });\n   421→\n   422→    it('should handle task_cancelled with no existing task gracefully', async () => {\n   423→      taskState.findByIssueAndRepo.mockResolvedValue(null);\n   424→\n   425→      await service.route(\n   426→        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n   427→      );\n   428→\n   429→      expect(taskState.save).not.toHaveBeenCalled();\n   430→      expect(claude.invoke).not.toHaveBeenCalled();\n   431→    });\n   432→\n   433→    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n   434→      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n   435→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n   436→      taskState.save.mockResolvedValue(task);\n   437→      worktreeService.cleanupWorktree.mockRejectedValue(\n   438→        new Error('worktree not found'),\n   439→      );\n   440→\n   441→      await service.route(\n   442→        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n   443→      );\n   444→\n   445→      expect(task.status).toBe('cancelled');\n   446→      expect(taskState.save).toHaveBeenCalled();\n   447→      // Should not throw — cleanup failure is best-effort\n   448→    });\n   449→\n   450→    it('should reset cancelled task and route to refine on task_reopened', async () => {\n   451→      const task = makeTask({ status: 'cancelled', currentPhase: 'execute' });\n   452→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n   453→      taskState.save.mockResolvedValue(task);\n   454→      taskState.getPhaseStatus.mockReturnValue('pending');\n   455→      claude.invoke.mockResolvedValue(undefined);\n   456→\n   457→      await service.route(\n   458→        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n   459→      );\n   460→\n   461→      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n   462→      expect(eventService.record).toHaveBeenCalledWith(\n   463→        task.id,\n   464→        'task_reopened',\n   465→        'phase-router',\n   466→        expect.objectContaining({ actor: 'human-dev' }),\n   467→      );\n   468→      expect(claude.invoke).toHaveBeenCalledWith(\n   469→        'refine',\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"8a5c027c-44a9-4934-8e58-c5b2ccdb77b8","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","content":"      // completion event was already in the queue.  The fresh DB read must win.\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'paused',\n        phasePlan: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent stop',\n          actor: 'zhfahan77',\n        }),\n      );\n\n      expect(taskState.save).toHaveBeenCalledWith(\n        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_paused',\n        'phase-router',\n        { actor: 'zhfahan77' },\n      );\n    });\n\n    it('should allow re-executing completed refine on issue_comment event', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_cancelled',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle task_cancelled with no existing task gracefully', async () => {\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(taskState.save).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockRejectedValue(\n        new Error('worktree not found'),\n      );\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      // Should not throw — cleanup failure is best-effort\n    });\n\n    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_reopened',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',","numLines":130,"startLine":340,"totalLines":1607}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01RzicwTwyqyKFNzrkG2CZfe","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014ns4iU5i9oJESPUYnba253","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","offset":740,"limit":80},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":913,"cache_read_input_tokens":81188,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":913},"output_tokens":13,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"4277a6f2-8b8b-4839-8a37-108478864fa4"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014ns4iU5i9oJESPUYnba253","type":"tool_result","content":"   740→    });\n   741→\n   742→    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n   743→      const task = makeTask();\n   744→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n   745→      taskState.save.mockResolvedValue(task);\n   746→      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n   747→      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n   748→\n   749→      await expect(\n   750→        service.route(makeEvent({ type: 'phase_execute' })),\n   751→      ).rejects.toThrow('Claude crash');\n   752→\n   753→      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n   754→    });\n   755→\n   756→    it('should not cleanup worktree after normal phase execution', async () => {\n   757→      const task = makeTask();\n   758→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n   759→      taskState.save.mockResolvedValue(task);\n   760→      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n   761→      claude.invoke.mockResolvedValue(undefined);\n   762→\n   763→      await service.route(makeEvent({ type: 'phase_execute' }));\n   764→\n   765→      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n   766→    });\n   767→  });\n   768→\n   769→  describe('resolvePhase()', () => {\n   770→    const task = makeTask();\n   771→\n   772→    it('should resolve issue_assigned to refine', () => {\n   773→      expect(\n   774→        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n   775→      ).toBe('refine');\n   776→    });\n   777→\n   778→    it('should resolve intake to refine', () => {\n   779→      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n   780→        'refine',\n   781→      );\n   782→    });\n   783→\n   784→    it('should resolve issue_comment to refine', () => {\n   785→      expect(\n   786→        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n   787→      ).toBe('refine');\n   788→    });\n   789→\n   790→    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n   791→      expect(\n   792→        service.resolvePhase(\n   793→          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n   794→          task,\n   795→        ),\n   796→      ).toBe('reply_to_pr_comment');\n   797→    });\n   798→\n   799→    it('should resolve phase_* events to the phase name', () => {\n   800→      expect(\n   801→        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n   802→      ).toBe('refine');\n   803→      expect(\n   804→        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n   805→      ).toBe('plan');\n   806→      expect(\n   807→        service.resolvePhase(makeEvent({ type: 'phase_execute' }), task),\n   808→      ).toBe('execute');\n   809→      expect(\n   810→        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n   811→      ).toBe('deliver');\n   812→      expect(\n   813→        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n   814→      ).toBe('compound');\n   815→    });\n   816→\n   817→    it('should resolve _completed events to the next phase', () => {\n   818→      internalAdapter.getNextPhase.mockReturnValue('plan');\n   819→      expect(\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"e3083753-c0ff-48a2-a1e0-70cb4fae55c0","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","content":"    });\n\n    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n\n      await expect(\n        service.route(makeEvent({ type: 'phase_execute' })),\n      ).rejects.toThrow('Claude crash');\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should not cleanup worktree after normal phase execution', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_execute' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('resolvePhase()', () => {\n    const task = makeTask();\n\n    it('should resolve issue_assigned to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve intake to refine', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n        'refine',\n      );\n    });\n\n    it('should resolve issue_comment to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n          task,\n        ),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve phase_* events to the phase name', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n      ).toBe('refine');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n      ).toBe('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_execute' }), task),\n      ).toBe('execute');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n      ).toBe('deliver');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n      ).toBe('compound');\n    });\n\n    it('should resolve _completed events to the next phase', () => {\n      internalAdapter.getNextPhase.mockReturnValue('plan');\n      expect(","numLines":80,"startLine":740,"totalLines":1607}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_013bZaXq3sDgt6yN2rw6gWkc","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01M3ztMsbk9kRwgiEmmGCGmi","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","offset":920,"limit":100},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":3806,"cache_read_input_tokens":82101,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3806},"output_tokens":40,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"37c34e3e-2e33-4d0e-9813-62f2613d340a"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01M3ztMsbk9kRwgiEmmGCGmi","type":"tool_result","content":"   920→        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n   921→      ).toBe('refine');\n   922→    });\n   923→\n   924→    it('should resolve directive to gated phase for gated tasks', () => {\n   925→      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n   926→      expect(\n   927→        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n   928→      ).toBe('review');\n   929→    });\n   930→\n   931→    it('should return null for directive on non-gated tasks', () => {\n   932→      const activeTask = makeTask({ status: 'active' });\n   933→      expect(\n   934→        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n   935→      ).toBeNull();\n   936→    });\n   937→\n   938→    it('should resolve retry directive to currentPhase', () => {\n   939→      const stuckTask = makeTask({\n   940→        status: 'stuck',\n   941→        currentPhase: 'execute',\n   942→      });\n   943→      expect(\n   944→        service.resolvePhase(\n   945→          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n   946→          stuckTask,\n   947→        ),\n   948→      ).toBe('execute');\n   949→    });\n   950→\n   951→    it('should resolve retry directive from event.directive field', () => {\n   952→      const stuckTask = makeTask({\n   953→        status: 'stuck',\n   954→        currentPhase: 'plan',\n   955→      });\n   956→      expect(\n   957→        service.resolvePhase(\n   958→          makeEvent({ type: 'directive', directive: 'retry' }),\n   959→          stuckTask,\n   960→        ),\n   961→      ).toBe('plan');\n   962→    });\n   963→\n   964→    it('should resolve restart directive to refine regardless of current phase', () => {\n   965→      const blockedTask = makeTask({\n   966→        status: 'blocked',\n   967→        currentPhase: 'execute',\n   968→      });\n   969→      expect(\n   970→        service.resolvePhase(\n   971→          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n   972→          blockedTask,\n   973→        ),\n   974→      ).toBe('refine');\n   975→    });\n   976→\n   977→    it('should resolve restart directive from event.directive field', () => {\n   978→      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n   979→      expect(\n   980→        service.resolvePhase(\n   981→          makeEvent({ type: 'directive', directive: 'restart' }),\n   982→          task,\n   983→        ),\n   984→      ).toBe('refine');\n   985→    });\n   986→\n   987→    it('should resolve replan directive to refine via commentBody', () => {\n   988→      const task = makeTask({ status: 'blocked', currentPhase: 'execute' });\n   989→      expect(\n   990→        service.resolvePhase(\n   991→          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n   992→          task,\n   993→        ),\n   994→      ).toBe('refine');\n   995→    });\n   996→\n   997→    it('should resolve replan directive to refine via event.directive field', () => {\n   998→      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n   999→      expect(\n  1000→        service.resolvePhase(\n  1001→          makeEvent({ type: 'directive', directive: 'replan' }),\n  1002→          task,\n  1003→        ),\n  1004→      ).toBe('refine');\n  1005→    });\n  1006→\n  1007→    it('should return null for retry on task with no currentPhase', () => {\n  1008→      const taskNoPhase = makeTask({\n  1009→        status: 'active',\n  1010→        currentPhase: null as unknown as string,\n  1011→      });\n  1012→      expect(\n  1013→        service.resolvePhase(\n  1014→          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n  1015→          taskNoPhase,\n  1016→        ),\n  1017→      ).toBeNull();\n  1018→    });\n  1019→  });\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"201a6f3e-b33b-410d-9455-6275eebc6f08","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","content":"        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n      ).toBe('refine');\n    });\n\n    it('should resolve directive to gated phase for gated tasks', () => {\n      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n      ).toBe('review');\n    });\n\n    it('should return null for directive on non-gated tasks', () => {\n      const activeTask = makeTask({ status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n      ).toBeNull();\n    });\n\n    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('execute');\n    });\n\n    it('should resolve retry directive from event.directive field', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'retry' }),\n          stuckTask,\n        ),\n      ).toBe('plan');\n    });\n\n    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n          blockedTask,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve restart directive from event.directive field', () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'restart' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'execute' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via event.directive field', () => {\n      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should return null for retry on task with no currentPhase', () => {\n      const taskNoPhase = makeTask({\n        status: 'active',\n        currentPhase: null as unknown as string,\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          taskNoPhase,\n        ),\n      ).toBeNull();\n    });\n  });","numLines":100,"startLine":920,"totalLines":1607}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_013bZaXq3sDgt6yN2rw6gWkc","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Qx1Bz7fFbz6ciwgojHDWcC","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","offset":1180,"limit":100},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":3806,"cache_read_input_tokens":82101,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3806},"output_tokens":40,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"d7e49e1f-83b5-4afb-b6cb-78d0f6ed7a07"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Qx1Bz7fFbz6ciwgojHDWcC","type":"tool_result","content":"  1180→\n  1181→    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n  1182→    // after the phase completes, not by phase-router before execution.\n  1183→  });\n  1184→\n  1185→  describe('retry/restart directive handling', () => {\n  1186→    it('retry on stuck task: resets state and executes current phase', async () => {\n  1187→      const task = makeTask({\n  1188→        status: 'stuck',\n  1189→        currentPhase: 'execute',\n  1190→        phaseExecute: 'active',\n  1191→      });\n  1192→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n  1193→      taskState.save.mockResolvedValue(task);\n  1194→      taskState.getPhaseStatus.mockReturnValue('pending');\n  1195→      claude.invoke.mockResolvedValue(undefined);\n  1196→      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n  1197→\n  1198→      await service.route(\n  1199→        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n  1200→      );\n  1201→\n  1202→      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n  1203→      expect(taskState.save).toHaveBeenCalled();\n  1204→      expect(claude.invoke).toHaveBeenCalledWith(\n  1205→        'execute',\n  1206→        expect.objectContaining({ PHASE: 'execute' }),\n  1207→        task.id,\n  1208→      );\n  1209→    });\n  1210→\n  1211→    it('retry on blocked task: resets state and executes', async () => {\n  1212→      const task = makeTask({\n  1213→        status: 'blocked',\n  1214→        currentPhase: 'plan',\n  1215→        phasePlan: 'active',\n  1216→      });\n  1217→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n  1218→      taskState.save.mockResolvedValue(task);\n  1219→      taskState.getPhaseStatus.mockReturnValue('pending');\n  1220→      claude.invoke.mockResolvedValue(undefined);\n  1221→\n  1222→      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n  1223→\n  1224→      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n  1225→      expect(claude.invoke).toHaveBeenCalledWith(\n  1226→        'plan',\n  1227→        expect.objectContaining({ PHASE: 'plan' }),\n  1228→        task.id,\n  1229→      );\n  1230→    });\n  1231→\n  1232→    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n  1233→      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n  1234→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n  1235→\n  1236→      await service.route(\n  1237→        makeEvent({\n  1238→          type: 'directive',\n  1239→          commentBody: '/agent retry',\n  1240→          repo: 'org/repo',\n  1241→        }),\n  1242→      );\n  1243→\n  1244→      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n  1245→      expect(claude.invoke).not.toHaveBeenCalled();\n  1246→      expect(gitHub.postComment).toHaveBeenCalledWith(\n  1247→        'org',\n  1248→        'repo',\n  1249→        42,\n  1250→        expect.stringContaining('Retry rejected'),\n  1251→      );\n  1252→    });\n  1253→\n  1254→    it('retry on failed task: rejects with feedback comment', async () => {\n  1255→      const task = makeTask({ status: 'failed', currentPhase: 'execute' });\n  1256→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n  1257→\n  1258→      await service.route(\n  1259→        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n  1260→      );\n  1261→\n  1262→      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n  1263→      expect(claude.invoke).not.toHaveBeenCalled();\n  1264→      expect(gitHub.postComment).toHaveBeenCalled();\n  1265→    });\n  1266→\n  1267→    it('restart on non-active task: resets all phases and executes refine', async () => {\n  1268→      const task = makeTask({\n  1269→        status: 'failed',\n  1270→        currentPhase: 'execute',\n  1271→      });\n  1272→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n  1273→      taskState.save.mockResolvedValue(task);\n  1274→      taskState.getPhaseStatus.mockReturnValue('pending');\n  1275→      claude.invoke.mockResolvedValue(undefined);\n  1276→\n  1277→      await service.route(\n  1278→        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n  1279→      );\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"5797a571-470c-48d2-b823-971e446bd378","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","content":"\n    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n    // after the phase completes, not by phase-router before execution.\n  });\n\n  describe('retry/restart directive handling', () => {\n    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'execute',\n        expect.objectContaining({ PHASE: 'execute' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {\n      const task = makeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n    });\n\n    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent retry',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Retry rejected'),\n      );\n    });\n\n    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n      );","numLines":100,"startLine":1180,"totalLines":1607}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01GsHwkXTtT82Eew2ogvZrD1","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017Pd12idDm6zgwoseqb1h8q","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","offset":1280,"limit":180},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":3143,"cache_read_input_tokens":85907,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3143},"output_tokens":40,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"323db6ee-c718-49e0-a85a-30d7b7e5be4a"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017Pd12idDm6zgwoseqb1h8q","type":"tool_result","content":"  1280→\n  1281→      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n  1282→      expect(taskState.save).toHaveBeenCalled();\n  1283→      expect(claude.invoke).toHaveBeenCalledWith(\n  1284→        'refine',\n  1285→        expect.objectContaining({ PHASE: 'refine' }),\n  1286→        task.id,\n  1287→      );\n  1288→    });\n  1289→\n  1290→    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n  1291→      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n  1292→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n  1293→\n  1294→      await service.route(\n  1295→        makeEvent({\n  1296→          type: 'directive',\n  1297→          commentBody: '/agent restart',\n  1298→          repo: 'org/repo',\n  1299→        }),\n  1300→      );\n  1301→\n  1302→      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n  1303→      expect(claude.invoke).not.toHaveBeenCalled();\n  1304→      expect(gitHub.postComment).toHaveBeenCalledWith(\n  1305→        'org',\n  1306→        'repo',\n  1307→        42,\n  1308→        expect.stringContaining('Restart rejected'),\n  1309→      );\n  1310→    });\n  1311→\n  1312→    it('unknown directive: posts feedback comment listing valid directives', async () => {\n  1313→      const task = makeTask({ status: 'active' });\n  1314→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n  1315→\n  1316→      await service.route(\n  1317→        makeEvent({\n  1318→          type: 'directive',\n  1319→          commentBody: '/agent foobar',\n  1320→          repo: 'org/repo',\n  1321→        }),\n  1322→      );\n  1323→\n  1324→      expect(claude.invoke).not.toHaveBeenCalled();\n  1325→      expect(gitHub.postComment).toHaveBeenCalledWith(\n  1326→        'org',\n  1327→        'repo',\n  1328→        42,\n  1329→        expect.stringContaining('retry'),\n  1330→      );\n  1331→    });\n  1332→  });\n  1333→\n  1334→  describe('quality gate override', () => {\n  1335→    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n  1336→      const task = makeTask({\n  1337→        directive: 'quick',\n  1338→        qualityGateOverride: true,\n  1339→      });\n  1340→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n  1341→      taskState.save.mockResolvedValue(task);\n  1342→      taskState.getPhaseStatus.mockReturnValue('pending');\n  1343→      claude.invoke.mockResolvedValue(undefined);\n  1344→\n  1345→      await service.route(makeEvent({ type: 'phase_deliver' }));\n  1346→\n  1347→      expect(claude.invoke).toHaveBeenCalledWith(\n  1348→        'deliver',\n  1349→        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n  1350→        task.id,\n  1351→      );\n  1352→    });\n  1353→\n  1354→    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n  1355→      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n  1356→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n  1357→      taskState.save.mockResolvedValue(task);\n  1358→      taskState.getPhaseStatus.mockReturnValue('pending');\n  1359→      claude.invoke.mockResolvedValue(undefined);\n  1360→\n  1361→      await service.route(makeEvent({ type: 'phase_deliver' }));\n  1362→\n  1363→      const envArg = claude.invoke.mock.calls[0][1];\n  1364→      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n  1365→    });\n  1366→\n  1367→    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n  1368→      const task = makeTask({\n  1369→        directive: 'quick',\n  1370→        qualityGateOverride: true,\n  1371→      });\n  1372→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n  1373→      taskState.save.mockResolvedValue(task);\n  1374→      taskState.getPhaseStatus.mockReturnValue('pending');\n  1375→      claude.invoke.mockResolvedValue(undefined);\n  1376→\n  1377→      await service.route(makeEvent({ type: 'phase_deliver' }));\n  1378→\n  1379→      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n  1380→        task,\n  1381→        false,\n  1382→      );\n  1383→    });\n  1384→  });\n  1385→\n  1386→  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n  1387→\n  1388→  describe('idempotency guards', () => {\n  1389→    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n  1390→      const task = makeTask({\n  1391→        currentPhase: 'plan',\n  1392→        status: 'active',\n  1393→        phasePlan: 'active',\n  1394→      });\n  1395→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n  1396→\n  1397→      await service.route(makeEvent({ type: 'intake' }));\n  1398→\n  1399→      expect(claude.invoke).not.toHaveBeenCalled();\n  1400→    });\n  1401→\n  1402→    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n  1403→      const task = makeTask({\n  1404→        currentPhase: 'execute',\n  1405→        status: 'active',\n  1406→        phaseExecute: 'active',\n  1407→      });\n  1408→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n  1409→\n  1410→      await service.route(makeEvent({ type: 'intake' }));\n  1411→\n  1412→      expect(claude.invoke).not.toHaveBeenCalled();\n  1413→    });\n  1414→\n  1415→    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n  1416→      const task = makeTask({\n  1417→        currentPhase: 'plan',\n  1418→        status: 'blocked',\n  1419→      });\n  1420→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n  1421→\n  1422→      await service.route(makeEvent({ type: 'intake' }));\n  1423→\n  1424→      expect(claude.invoke).not.toHaveBeenCalled();\n  1425→    });\n  1426→\n  1427→    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n  1428→      const task = makeTask({\n  1429→        currentPhase: 'plan',\n  1430→        status: 'gated',\n  1431→        gatePhase: 'plan',\n  1432→        gateReason: 'Awaiting approval',\n  1433→      });\n  1434→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n  1435→\n  1436→      await service.route(makeEvent({ type: 'intake' }));\n  1437→\n  1438→      expect(claude.invoke).not.toHaveBeenCalled();\n  1439→    });\n  1440→\n  1441→    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n  1442→      const task = makeTask({\n  1443→        currentPhase: 'execute',\n  1444→        status: 'active',\n  1445→        gatePhase: null,\n  1446→        gateReason: null,\n  1447→      });\n  1448→      taskState.findByIssueAndRepo.mockResolvedValue(task);\n  1449→\n  1450→      await service.route(\n  1451→        makeEvent({\n  1452→          type: 'directive',\n  1453→          commentBody: '/agent approve',\n  1454→        }),\n  1455→      );\n  1456→\n  1457→      expect(claude.invoke).not.toHaveBeenCalled();\n  1458→\n  1459→      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"a5b6277f-df13-42ae-88e5-febf5f8df053","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","content":"\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent restart',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Restart rejected'),\n      );\n    });\n\n    it('unknown directive: posts feedback comment listing valid directives', async () => {\n      const task = makeTask({ status: 'active' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent foobar',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('retry'),\n      );\n    });\n  });\n\n  describe('quality gate override', () => {\n    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n        task.id,\n      );\n    });\n\n    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n    });\n\n    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        false,\n      );\n    });\n  });\n\n  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n\n  describe('idempotency guards', () => {\n    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'blocked',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'Awaiting approval',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        gatePhase: null,\n        gateReason: null,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent approve',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();","numLines":180,"startLine":1280,"totalLines":1607}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01TbPPpoqPTPZS6AeFBBfegB","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Now I have a good understanding of all the files that need to be modified. Let me now implement all the changes:\n\n**Production code changes:**\n\n1. `task.entity.ts` - Remove `phaseExecute` column (lines 52-53)\n2. `task-state.service.ts` - Remove `phaseExecute: 'pending'` from `createTask()` (line 42)\n3. `phase-override.dto.ts` - Remove `'execute'`, reorder to `[refine, plan, deliver, review, compound]`\n\n\n4. `status-override.dto.ts` - Remove `'execute'`, reorder to `[refine, plan, deliver, review, compound]`\n\n**Test file changes:**\n\n5. `database.module.spec.ts` - Remove `phaseExecute` assertion (line 67)\n6. `task-state.service.spec.ts` - Remove `phaseExecute` assertion (line 61)\n7. `phase-router.service.spec.ts` - Remove `phaseExecute: 'pending'` from the task factory (line 33) and update several test assertions across lines 356, 397, 434, and 451 to reflect the phase ordering changes.\n\nNow I need to go through the remaining test file and systematically replace all references to the `execute` phase with `deliver` throughout multiple test cases, updating both phase names and their corresponding status values. Continuing with the spec files, I need to update the phase-blocked tests to use 'deliver' instead of 'execute', though I'm realizing some of these tests might need different phase values depending on what phases are actually valid in the system. -496, 501: references to `'execute'` in the string check - but looking carefully, these check that `'execute'` appears in the comment body (which would be the phase name). This would need to change to `'deliver'`\n   - Lines 588, 591, 598: `onPhaseBlocked(..., 'execute', ...)` - change to `'deliver'`\n   \n   Wait, but these tests test `onPhaseBlocked` with `'execute'` as the phase. If we're removing execute as a valid pipeline phase, these should use `'deliver'` or another valid phase. The comment expectation on line 492-496 checks that `execute` appears in the comment - this was just checking the phase name appears in the comment, so we'd change to `deliver`.\n\n10. `compound.service.spec.ts`:\n    - Line 71: `{ type: 'execute_completed', taskId: 1 }` - replace with `deliver_completed`\n    - Line 89: `{ type: 'execute_completed', taskId: 1 }` - replace with `deliver_completed`\n    - Line 128-129: `execute_failed` → `deliver_failed`, `execute_completed` → `deliver_completed`\n    - Line 134: `expect(summary).toBe('execute: completed')` → `expect(summary).toBe('deliver: completed')`\n\n11. `watchdog.service.spec.ts`:\n    - All instances of `currentPhase: 'execute'` in test fixtures need to change to `'deliver'` - these appear across multiple lines where task objects are being set up for testing\n\n.\n\nNow checking the claude-invocation service spec file - I'm scanning through it to find any `'execute'` phase references that need updating, but so far I'm not seeing obvious matches in the portions I've reviewed.\n\nMoving to the metrics service spec file, I can see there's a test inserting event data with `phase: 'execute'` that needs to change to `'deliver'`, and then assertions checking for results with that phase value that'll need the same update. Now I'm scanning through the watchdog service spec to find all the remaining references to the execute phase. I can see it appears in multiple test cases across different lines - in currentPhase assignments and in event record checks - all of which need to be updated to 'deliver'. Moving on to the directive service spec, I need to remove the phaseExecute property assignment and continue checking for other phase-related updates.\n\nUpdating phase references in directive spec... I'm identifying the test cases that need updating—changing `currentPhase: 'execute'` to `currentPhase: 'deliver'` in the directive service spec, and now I'm checking the phase-router spec to see if there are similar phase references that need adjusting in the resolve phase tests. Now I'm scanning through the hooks service spec file to find all the places where `'execute'` phase references need to be swapped out for `'deliver'`. I can see several test cases around lines 472-598 that call `onPhaseBlocked` with the execute phase, plus an expectation that checks for the execute string in the output—all of these need to be updated to use deliver instead.","signature":"EvJUClsIDBgCKkCp+7TfxWvzzDW/F6TPFmwmXxZsXnJTduz6OiI6k/OVQ5HHAPa4xQzqIiLkxYYCgiz4cPOntpLl/gwF1fBNGKO/MhFjbGF1ZGUtc29ubmV0LTQtNjgAEgyhLfDV12FBlFKAUZMaDNNRdvCNEZSej4ar3SIwk0+87+xmiEDWyl/7PtrLGKge7goyZhmy5XZc/fPnpPt12DFEWUZEnZMI/2c0YRPJKsRTb0MNPdZSwmoovnzWgfnhMpbiCo7r30IWVcg2nHMjJ0baRitp3O9TCoq4IOSvW6yMAimn3cda83NTzqFaMWncBQ3XGRvUQrxcXGfQPPkpolFNlWRcXGib147vb08wB18rKfF5AV3qORhDUtoNode8GhX1sfA4OtTZ4lbn1evPhzJFpe0f+L+F7xiWnBg6IP5jRMJAzAtVdpu7f8AmGcg/3aM7g7/9l1es4qSYZCZKARI1vuHXguG9/8XBY3R+LKBfRFdW1pxayAPw7pmloUMwXuf1+QvOCMTvdOpYX6tiUrOUONlHkda+Swm+0T7CDTM4BvMWpWFWDqhBplQk46A26yLH3f4phGbj9R7UBig869KwrP0hkvAt+wEoRRPYiO5TbMljU1tF+3rIV6SaYJb/SpmsHCL00cGf5I+GiyG2rjKXj6hA6Dmjvrp6H0CEgbmTcU78+y3iVZUXojucILpPHpLiPDicp230j2r5MrDk9xQM86B9zWbcDFc8p8+7IqzZc4mQSOgg53v1NGGNrDQpGQoRwIQ/mc3YSx5K85jyyQWvHVqY0zE1hEMp/VNEAWIQHxzxoKu5TvRANrf1pU6Yj1oMA9QUUcp2/K/0JRqaC28Bm9ccO+mmeXgnDl7LgiGAUJu9qPa7Az7EKEYKQVcJ7lVIc0Do3eL21WP0KUsH9BXAKMpEAGyv+9+yGstLfT34mwpeG5382Av+kooAut8WQq/1dQU0z0pM81EiVk1+rCot7whVQru4gIdeotSdjxmRKboWOsgXYwAfHDibN5kyeKzN1FywHQEh4dw/1w0BD7xBP+QVHHNq5iTtlZ/MbYq452GJoxm0GfOWfONyx1M9EBMngYCN72uLGjlM8MBkURIAd9VEqWsAwWs4BvYG/gXXq06wRXINNc8cX3cJZp1Y1v8BnPiBuV4nKFwVGTJgy4T+QGJZUt5wqXBYEIPeM3Z3MTX634QhTqU1AYRV3MulKykFp8sDOEC8TyeoZx7ePN82/nObFEoE1NCMCL5U0zEVDv+R5bx96z2ITw5b/Wit9Z7fTX1w4p4N2LxpmlWUN54bcBKaq4HCcWwV3dnsfeySsAATZk7B6PSEMlJdHezA14/EEc5rAdHrTSFOotkRWTEioRMCQF7myGZ8BJU1ZzRey3okufpV/snC0GyT2qjnVpBg+LA4Y5Lfk8pMHrQuhwsoJu4IUlVAeQnAF6k+nd27CYcPk+lDfFJr8BN7M8LCNEbGYze2q9RT/N1eGBRSoxSnJhtUmzHgsBaXjIytcsK/U9MKIWK4eG1RAOzf3yXsD54as0Ix4O5g0q82qqxWxOpC92diFnJ/yPMGZ2fhPS8WjM/jSnbMhwqynJ9e+1jWzGHEimRjlxg92Y3e9qxwzlXV6cv8kGTlOknBBeviBRw/+P4rTJ5kxOs6OGAW8L03HUI8EjILNAJsTeze78j2b469tnwqEP8Cxl4SigTMogSH000kkbtIuF3XKoOIKbAk/mLkd4hOOORsnDcBoTMG4alEWkNXcPu38r/J2RlRiVyn6vHTFEtIdOztReEcZlBE4AbqQijU1tutGEsWC3YBzz3VdLztsua6zNJbkYHQ1EJmujqYamaGYAF8DLR8YcYpZT8yzUEzKQGyYhn9CmZ4FhuS4VvxhdmfPAA0bq96EzTuNvswmIh+07O9iMYQH6ENL/qgyMIpIU4Qe9+7pQoYSoppOUU4GAsZiGrqsCC+dljVVlgVuDVUCW1a3u8xp+wI+zJ50kCKr1dOiVFf88GtwuCBC0fsPor/7myYODllZj6xtjxb+tT3mwCzhRu0MjiJkzrvCUtye8PXj64tG3YjcxCnz9rqMfjqPmr+WbWvp3QW4SZLdr2zRbdiWWzmEBemebDNWghIfOaNLKe4nhbij+oAOddlRY7LTA9Aadeu9ds82TdSQow8thS+JsCrG1WFjhX1Ng1lPhOFTwfj+6UFWyGTrtbzLPM8EHXOd2v7HBJ7a7ZMYdZ1olfrbEl/wTV9tvy4+P+hrh0hujm3/Mtasg58rGJ97OzxBK4wr1WbON8zXm92u4DCHW7wuYchkTFZa1mkbb2TYrbkXp5hiuH0WJQEHFsuEgdZJ0Hh62jk0m4OrRU8UCzCM57QMhgvuwlG3HjcgBQ7/tsfjO6IK57Por9lXgATksSnhGQzbTPH88ZrtaNX0axF5hft5r3x3+8czHci2EgHoIrYa2TRpeZqoSggyqAc15QwQ2gq0LJ/JStzSf7xxNXQl+mDkwEEtFvI8ekqdVEeV3r4bvbeNFPsxsfDpLvSqWsHCnKKz3A8vdbl5UzX2uixNZx0zC/8+GMKgx1lGjcXDF32706aaRrFY962zo1Qv+OBb6Hux62K/QS4nDakn3xAwLkpIzArRvXc01PYf1G+wjTQD4tszhcIVeWT+Piow+irn67VbZPXg/gt4KsJDyK21zl/5fUXbRF2a74uvo44ABYgFTFEHn+bGYyjBWnTufL3dfN0IdEQju2XV3DI7VpkQnFRxo+VrpTqKTJQF/P8AFLb4Z9a0wJNoc9NMnLifFUk1WigptUVyJFyox4jccS//OQhKc8eBisnmhtU9YNkgKmo2iWh3OiulWSVBmwz2dUibZsSa8RDeXgk4PnouJZbzVIX+jS5XTwsUcRHuNycHOIqEGqPF7bDM2icsYjIBM7s4snAsPYgqhN1feguk4tNMlqstrHKK9ey9iBJfky1hMdOzKE+Zcl206nzzXQprwuAhQLTnWhy/FjJF9Zq/gI3CHDc3uIrAQCwL4by5+SwSWCh4RT7ljqYwMFfdSELUBnb2xmZSjQcq+xzi4h1hg/2kRVcymT4DSP9XT7eh0covQ3bbSADT6e5ABq8DOZDgZVedanKYx3jdXzFWDG5LLoE+H/ITcwIf4GHzu+3KbBfW0DpNCoNLOJX/NwYjKDb5Fbhl75ydHoM6lLN5n3fUqkxVf913xwTj3AZ3jYn3DT0ntmh0abY5SR9kb1f28vhAv4QeqvP79y2/ujaEQx+WwxcupPIybCgtflAT0Kri6mjJt3H5FmlaF71AgHC8U8ajeF9gksJdSLjBkKj2cr4/NGTes8HdMmhk7gZZ01OzhvNtV/sCghzf04huDkl+lY2yz5wggvmZtmr7olSQVtDffgnUr7GmUkp7k8Rx3bSOqbJmq6LjRbR3RkxAvFz3IjXBSBgGxeQCDV1kY9AqPmH7JSZ0QwTjOqd7rgh1pfW+JKkHb9V5VmSZ/ZJGI1TKTvymvVNad9SY8rQKesXCDDqiu3NxcBsV+JmEZEHvU0uj/iG2T6vomfEtgjTyC/brmosk6xsKWSH+DZRdGH39hLmRWEPYURZ1iv6BisX0vpv/qf7d2QN1WusFy7I92n2Gf8o4Z9DMXlDKzIVLRpkizGYN1OWcD0gy/VRJKkbQX8FSyiKrxHNhmfA+aTGCYe+DPzjUVPV+wqx7LpbUSnuoC1GREwUYX6pIiX9tDJNZpSnwVOY1joU8UHyTFILRz5dpWAzZCl+EA377IuBtMn51deN00qsalMae2sz3gHe3cMEntLokokpar01kVdzl4Nf8YZWCMfHZe7OvBS4OxAk671cpwdR9xM2QXKW2honTK01R9aUIQ3W2dKpzAOx4dj/d3P3hpqh2AVSLdH6ZUt5Dt+kjl20MtNo/0e04D6kGCOkr8nossOYmvDVnvkUCLXMLYciBfLIjWBmOAcIjXrhQWP0sAvfqtdgSoIK1gQSXanvAub2UURuywhUWHHcJGF0arj4XZxpBBHS0hwsCDwg38YR/oQpDGPthe/BQE6gz/u6VlMVvD2t/Lva1pICIL2B16TAAIUfqrl3uO3tzo2wmsMa6/Qa3OG2Sqg2vcEBf4smNTik2aro16f0FJP6V+4i979xvIF6uA/ezyqLpv6KUmPw/WoBjcUJDBDzO2nSMYWhvgc+GVyof8LkUQKzuXj9z634UVRXBYhIX23U7sLD+FI4hhk/lziJzoZb/ntbNB1PxczW26IEPh9unyH7iE7mdhTOI9RsdFIoiyvXoaMbF4udsnrhLe0UdCWLJhqGPEBUh5Ors7vbMZvRIrPdknpHPRV9249A7ixtOmtaRQ5lXDuqnaK1ivnIhaLY9DzpaGpec1ifBuaGZPehNXzQ0uSLTkNzY7sfOII0U4YErK/K5QGhdzpYJ7kL0oa9MtHzgkqxwKQyibecGYpgbUjcCxwfbnz0Eu+zBWB7/RscEjKE9Mpc22oPUy2PQTXl7Kg6fbK3Jzrr7J0IUOFqkK0YLDBmG3TkY0FcoNHNayVTzrdOpvLCD3Tgd6PfM8Dd9dsafwCG0/g9LkqLJgQjfOR6GkFPoxi+VlvLCOtgXRIT+n7DDYFLpJ/RfW8OQIGC/et5i/U8HwaHpyfBgi29EN3T/lXKjLEwiFJ57OA0YM9WrATuT9IyAOhWSSNjdLLR07s/q6RItnK1QYUDwmA0mM5HfTnN9Y0YdMgz+uyHQ4Z0lrF9QagNdbKdK4+rsxXthDdy3KsF5lNE3ZZz3bMJboqN5G0liMVMoH9hBUbUjBW8voxkXyojUQBJ4udeI2QBuvFViXsBHTQkeiqGou8RF+RYsz6A2xsCzeTkzl/+KHZJeGDvfnPbSZMLU3ZJ4jAR7A8AB1sgeZvbHcNsNImQW+YrgTqm5zEiAqb6r/9lrMbDRS3ZvOCi2Sr38smOp3xEmCdiwBQU7AMrDgKRh+uL+m1s5OTgPx/zNtYdweMDTdmddBg1mcbTlA33MM7NJ171BrIgg3dXdo2uExawA1j5rfRj3al9RBxop1kK0zXG2a5ebJqrbF3DKbq5sLpFO2YTxpbsFygtWtlYEqj6gKZaDL+TGyCV8VJj8hlLMQCpLFigDJRUsg/Ve2giMk4dAgZSaLXHwPORgglAtC51VrL1rv8vj78li0uejVOpbyKWnwTL2RVyZXhPrb8xoQW6/ghCor/O5QuOOip9u0wdLO0ROFujRApYio4MY4bvhhgLD9WO20Ina5lYMtdIHgrHP7zSDgmATGfxIKm05HFhRDOoYp/ESxksk1lL+TMHWK96NTs7V1M+pQKniDgbUfaSkTyt/sPwWUN6JYp+wBPfQNt6wCVTAXf7dCs9czvoO8JZhIbPaJaDYgfkF5+OwwQLbUd36TwUcwVxVgO8QMLomgnYS0RUHGoyHJ3eEf9tfIndaH9ctBADWRR3VolZ8pJ2eFNiK3W16BXkt5UuDJwmMJCWPXAKJWdwfmelCQcNkE7D2r3vnnQGepmxWpIo5+ULFu8uPvfmB4knYFMEYq7p49E6HU+0BNVcBjlGhJkhuT9CS16A19PFBGYdhYgjpFMButBlm+B786UbYk3QURyJZlxP8xscohPBxNVgs+xiwlqgVWfSfX0VVMkOn7toozskXV7/Jm+7ipcEMw87hARhXw5lWBQ2Th4Zran73xc6F5EHjS+xn8UN/bO5ylkLlt85k+1ldaBCgUSAry8bC9MSyeNtrE6pNXvkMRXNiOaRZiYcS444rMLCLtTj2iJsrYzgMcMiYGQlH6RMZPVz4NKU0jWJ6bSIlkuNPE5OScZIsXJJ5FCzcbZSakhK+prggqGwZoDT23x/5tARGoVaNpXqahjNWYUzKa15nKMm8liXpmCQ6CkOyXYdMLJFGn67YupiBuRqIVkEdlsZ9yy1+2IFDp1JmJyHFqidmnyVuBqw6DM1tfUuTbJ4bjTQRt886GSggio32OPppsAwnnmp+OMP+Hevsizk3ATNOjRN+/hUqNG4jZBY2H82hMySdg/TOnUHkQxMAWtXIWMiu2jSWFFM7lzxrZPgwxIXkwnzB2saKD5toMyidjqaXC5EHeGenIvfuCch5Be8hBS+jVVwjp+4657/cDRHRSM3XeX/lDdjseeZ+Ok0GWDGbXBdWHedne76Y+5KtS7rNrAgNj1GrpXhfX7rNnshJy/fdGfNYPj7Sh86eSei1/JsQ1ETAi+CIdY8R1Tjas4zh1uBMtKayyJWACR3anp6WM9XXSzlfC+8YYPF+u3j62Wu6bSnEK3dTE2t568eAKgytkhw1qw4+TU9SgI4lVD0Y0R7SDvjgxvZHZXIupx53yr3QqzPktP9yEyS5EEjef/EDP7aq8Ow2Viq8LaxiQAnx9h1kxB1REM8APRHOB5N3j9S2ziWcUPaP3agBx3tmjr7/wsGTk2JLdcyWWzTyPkNnFfHH2NYW3GpJvE7ojm2EMtxpI9BD9ymA8EBYe+Ti5TjN0nIWf1e5oMkmfVUY0zs7hFTjk43bcyFBKDOXyJDcmzIkaxEyy3KyZ4BsR1s9l/Me3sqV4cz1zGBLqRSpm3L+tIeaOJXufzmkl9dl2rFtCxyQnxj0s4RBV5PrNdBmydPToLAfCF/bGlqsSZ3z5l5SuWBRzDpzuU2mzF4W4NK30nJ9ISWQVyarTDsfIVvhBmzRuWVhuFQvv6ylD+gBxGEkujKMin+ktjNDVeD3uh+tGhGV/Ij+u96dyOSR6esH90U27oVMQ2BKkZvNAEOYgSXCXkecVB+5iy04oVR7S+Wbva26pdm7/vED5zXsX+3Kauol7ZsBz5V5SO/FHonF+fEmIBc6asFuKGIYZufZj2171+N/rzJNem5gGoO/hsjuubUoZZxsWorlNACU3YKKauwqhbyxPqbzHfUVUl7xt9lMxQGxQ1dVN1+oV8HwGOI55rsTGxME5IRfhYbdF/ql6xjSPwTfoTyeWt0uJuAvNKTVXeTK4QskXIhgGHE+OjBsE57vXV8rOP/b7XyOyDWkjl7JyI77F9RHEo14CjjygINoa1E7YyfCbKgUOiNvMRNhdttuQZmooj22VzOLHmDXs+B6OZix9eFIzQFfBpDLVyeeJ02qWdeyEu/yhWJ9zt5LT/OTiJ1qDuTvjm/RnM8h3nLx/Nczmj+GWhK16M4ccsjuDhYBxgDFt6stwAlUu7vIg8oGL4kDOr3TOVQLIx7VvpnoJsS9uRlxqWNSuT2qcDnjbFn3GGg8scSlbGs/jJRUNCLsEvJONfqirazRaK17WSyOO57lVQ49h7V39PMat43Won2DegHAOxvoA7qvFcIGbxmOhck3CPYRhCfUTAEEGkCCfrZj501P9fPwQNWkV0O5GjO9cKjg7USL2s8z4qG44JHQBhHiEytMy0zXc2lYdJX0ZqWWGVtjLN3hFAekS68DFBEd91FRg1TshWDQhWnYP0sx4D8FRDoNey87b3qdS00rBrOOAnI72T6uyoA2G0iJax5alhzfwSznAH5DApodRtb9CFN9pAeF58BYbQHIFGd0/qiuG2c58ckXLrtByS76UiZL5o85s0GmZh8SNxQBUNYRVmkJyW5lnygY5SJQeroPiwL0EdTEwrcny1zUqZHpRQJpnjYXIoedw/bLSjm+EeOkL6yPAx/fcDi91HnbpI1z6443aatNnbLqi0Xt0Rok/lJKYvGcJTaxBGrhT48pk1bG1K+khyh/1lxaOEuVT2dJZ2D/17/8AcSejvfiz/nSmomKzaT3vKTsXG02RSyita3wz1Xi6812EtkBWNEQkfVHpPn1GbUh3Cp9+eNAq1Z+IucIvnTPrEIoTTd52YQotMP68txbDxTrDOFac88VNfLLxED1FBg5REesWhRbjdoOV64SXyB81SNS8lKCI5Yyd7R8bTsbFEPIenoFccsXJzDFLv2b5cBHMAGiGjeNzncNzNUb9Y5IYvCjQ280BjYL2p5Vckh9GRlG6u0aWq3l6wtdldUlS92iG+GQOc6H0SjY+0lGfIskVGh8v2pTwyIcqe6U7iGLmSnDWOsfJf43rpfm3LCK5NHj5Pk3qP/OFSG8Lwbq5Z+tij+zvb5l03ghVF7+obyvtpMEEqYlqbyZL57l3ARO1CtIc2WmMcGPIBKMpbiA8cX6y0q23kC6liNgiivp7cZ7LUk1wm0p7mqq5PVFpNiiSW1U02FlDfloKKfdNaWQD4awSa8DZaO/CcHZyfjKd1tUEZVCkTbCJ1z2XcuC0LpaCUFIscr8PwDgRqDGUQr3Rj0VkyfyHeD9ETGuGsN+HTHrhEiFvWJEfzAUcEvQdnJBB/F67lb/dWF7wCwb6V5kirv6ZOBPrkA3J/RZvouAUyF1kWoBV+Ad3mHl+OoMM64RlOSYMkLM4BuhXiSOT9gj/PMTwrQyqT9T2a2uA+5tjSRDPbxiaUJB6sTl2P5gysdaE0JJOKe7HwdIbRgXG18miNrK+MK5j0jRnuJNW/WEWv6u6euhlIY46R6jsBjhqGF0zzirTCU/IFqLZia1JbxaNbutw5R58sRdPZIQ9OeXF04zCLg1ntLWS0gktC3misaG/6BrO0w2XU3Clv+NzBTPcvo6p6HowNJx406c4HEjGySvjlBXtqfCjbrMVuYKXAJrW4PX8/8HXhpzJ3AjDH8+pyIUUcTNOaYsOr8kcJBLTF6GqPs7KzeVDxnV32QaKJAtCGXHQCcwg7Vpo5bMID3ntkMs1rrhQsdHS/Rfg1n1Ubm2JHvyRRgNtxUDbjSoSnM6+xVH+cecwEG1PiWhvozPvs8n8oQrdb7km0MdImN0an+EZntLwhaJV3nOadh2Vpv9XIu55fvTq5hamEc8tX/b4syh35JsOG2OVLMMePm3w9uWxdjjiD9QLolFcV2gJGv1XJmyoFJqQrzWrhr88gmsofFh6G98dNI1x9wQqMfr0ebAGD6XVm7oYHtPyS29PVUEk8iHHzAJvh/fo+tDIhnxDmERPPG4f43lOWZxtY+Ww+IiqPFZ5dFnVgWA1zxZ67KaTcT2vJvH+RHgn9GepRPQrOWvCsjW2Dzd6m7RL9T19i/AmMcWzhpc2TmsOHMf4RLGhiTBE2KUa5wSu10dFRa/sNENrSv69Ag9h8BjdkWzH9sXCdXEnsoi1AG46qXnPkCSX4kFH19zdEL7kjQXVwaTDM54+03BG8RZkgqfIHEAo70gDcDcXHE+s+ZTQs0RM+1/nHGb17AdwM4799r6pkcTU13TduQ/xqwPFmY+1S0DbKi0KYLjUEB3dg2KzPw+FmZlyy3lWcdvawOY4rT0wgxBtXbqgfcLbcPw36akjlDekur/JYR+qrL+diG8/oR6JPRewLPbpLSJLMIQNP9rl675ymyI3wq2U8w7CI2Rpq1/EQ0dtw9tZG00KeVj65vrAd40MPdOcPWmYeVBO+55R+Hdl/YWYXpL/LFutXvSB+cPh9luQv5aCuLJYq17XxY84WLv5M+qa1EyeS5LvsbuL0WxogKXMDwBcdEZ8rUL7laVZFKyNwRMrRpiL+6TM1bWTG6f6MyCOEsNZFzN15PjJbSy2/x2EVgFyRvbCebJds4pAe6PLKJuBQFQwEZIOykGdEIx6nVOxZlkx4N+zYJczc5IcuchKlodphVkhEUhuYRgObwR1x4YcikQlc0Fv17YRQUEVNECX6TRB6IjoANcV8G7uNGiCZE6OE9kOjSt+9zDksQgG4jqmoSGCIAycRwwN/ufXPsd7+cI5yunotcQOZKODULnVED0+XBGgc/ZgnPZFT2A7QgMd0sth/pBE19yf0uLrluyl87M8VjhvgZspJJ5Y7uj0i2FcclBO5Lb9uVM9GT3ICIQJUL4cnc98/KMLW6Rj789dToDv1fyboqLY+p4ebSMgaI89yy0NF1loaov8TU+sc0FAz2zbaFL9NEf+ViI0rCbKEDj+c1S9C38C7QMUXP3cjH2YR6YpyNGTd8tRiYhyyjF8/GlUvacsOKBVTxIMh63jcoOrH6CZY3oC+yti0Q8fjgUrg7vrX9TTwlQx/RCbKZWGohm+VWS1nBh9EF0fRW1cuF1wp2Fb46+dUCtm00ftuNzveuV0KRPKYlCLPfsiLNmNwwFCSpIFf4dGcTtTuyspClfaKnUVKgUpEtOH8WKfg3wSMyOLQXS/FWOqUjPYSVRhTluyrKDsy0P3gc5ic3ZmSHObaphc4UxhfmZJ6wQFIYYxK4nYmofHl4objb/K2TEmZC6QS7y2xngIGYoMLMrDQA+K5ncpsTmOly1ed0sFlnLzJZt/4OcPTTNBew4FFuDyXaJsBLtDwv55+CffDbkJbM7GTygJSYfDzyzrEf1eqQLjuS9wxXOWe8IJGJrfbKJJY+TACzCjo25kMjrGAi6Nv5rHWJlZIDFKWOnxUr/9sB1TzjhvlNEbSQ5PHg2uzZCeMLtsAGWgFKfrzmczuhOIEJV+b12ORO7wvgjVJdkacxoH8JuamSjBB1Q69KqoWvOk1O5KhswFRKvveDsjXm4nAGPb1J5GW82zRzgrWYtrT2KfVattebMoVo0OVAlxDTfHXGq9dwK8YTsgVPOJyhvUEjHiWwnF3P6+ZwtIwi87mk1quchUZXWHoVC/c0zd/G9KKZlozNCG/i40AVhdn+Y6lRc4iazlHeFTmTXh/OERGl2RTXvmwXPUC3QNox9kE9i1rmCS8zfDAXrTTvWDQbfrTATBD1NS5JnZ4R6vgZJyc8uHI2yyMz40bBJ/Ka1xCXUsfNXRSUTgtg0uuvqioF59CnnLbftbD+G/hXmPmIVpJT8okytUxzcutKW89WHoGe9lJe6HYmI/eIyj8RngfRaePFduMgFLeJpYHiTlY05M5WTT6XzH2rAhBsr5pZ9jXRCp608N7yfL7O78ZrEcioqdWkZU2xJTxwDjk53Zk20yYhKPcWzBTzWu4E8Y6AaCFKqLRnNYZhgcevGWJ7x1H1nat+yJpfzVBHy39KR47IGRIu5dZcSgf6OC5l6W8iuhpaB6Cr0Hxg8VReIMriNpSiOxnh9xOaW2F8OHrmwa4iMHQEFZwZimFSKutMeq8aSgeVe8qGv+mYGwnhiiOLwApRwaNsnigo9Nw5EpDTFBkl7ZvneH9IdNRxf0parkPZ60j6W6lTnEPppMfgJrvUQ2lyq467ND1H+HfVdzEJQCsZRPMmcwel3fFMF9ScBF+OvVMovzKd8nHGu8KMV/L1Prjp52Z9abB9jgeJYW2QBZbGe2Qc+fMZ+0o51uliwjPi5Xa4s5v7bxwNVslWD/cJvjAO/6hWST6a0uLlPGl8lpqmx1msM7VvBgMdkbLAO5r7vz7ajubJMiEZGxqDy9uy2xeO8ZM33RalQBY4u//3bgIdRzn4LtJ5vm+OvlInRpw7HhjACvZpMy+8JrJ2HhmELSX+tHv3EXCMyAx+18BMrEcAEAk2mh4bXoteNTNLPR7vYGJ4SvojyoO9E2/QPUL6UayCCavYurRoDPrimYf+fKS6C1fsU7+PGRsbkivFSmPY/aOkqiYwSt5jsSkf+Ov3wXf3myAMLmwfm5HdmBq9I3NT9KWirUtyUSVfWYsucGmEc28ejYOG/1LzileFG9YrGm+F8lNe7i1iZbq+4S8PfgDCJBarmOp+x/JRKlH1VZbx6fN7F4kwYzt09iKdJvjzWMj+peSaitIbLFywaRZOhrbRMVsNjXn1/DWKIfcUNPtotdLB0Z7Z5VBM/frXKoizhbVl0VtkRrnt/PbHdtB7sFGfbMlguGh0J/1CXHa6xobyt8cx/5N/5Gb2hrHYuu8bpML2gwNGWkvfA81UyV5FxX+SkAyhXmdeQyLBKTjVdDxn0HWBp7gtWcNQXcLfMnHDn1PxfU9Cg6KKEjO5ujVCo9Zr+tB6I3x8ShYzyu0sgeT+tdWF62aOjolmiqgRj97qMzxdTpqD+PkRy6AyJCMp3EP5OClRzutDeVNBATiJQ1m3582y9HeYkVlgBlXuWDjjXqGihJm+sQZ6LUsFe9z3zBJgAin1muCsRB/Sbw76EAZmz4TsMCsEQYcP+Nk43HIxmqymksh3D6u6D/wJFc7rOUBiJuEjpZeXvGl37OyUe/QHsGeSBRT67MQrvEFjY/C6P6fsZPmUmRaCPlQ/xr9leF7nkgp7Y2jWTMoPRpMNBwvNk5whio/beYKR4hCED6S9kDwEz8WSbgvxOLSgORj0NNfbw7Kn0Nqu19fTA5xVT218WHg7ewA88SZLj4pfxVC0ERO847W9SaR9PqmDJyLtTM/nJnfO0z54EsSF7Txu6lUN5uMOUdBOIHkgsOrlcXjseGEcNRg+H0FCbVsrjuGBqgwiI/ZnEBQZtekZP2GkNmrHFo+U6FyrKZl4jhVtbEq3Lmn0WE3s+7lIleDIr0EfesgZfk9PmdZSYONdha9RMZRH2gFZohNEFS5LA6IGQM1S3/vL+ifVuVA3kzZsCOoX4GN4C2TpjADNAi16rksfAGzlAfVGgBH2IOpwr0V8WceN3wEHf3tukOq3Snw/tnQ0mWYd2Sv1cpJBUCLZMOtseduDcz/X0QPd5Qnl0C/+akwuFAwV8C2PIDDxuTSVvtVSwAlOtCsJ5sN65v4kB9HeMhVwignh9Ac8ikqu1PkvSyq3PYlz66UW4ar+mxaE4iS8euOzAp39KtoEZBZeDciteGRjzZXKpNACy5viJfAZuBVRHmkDYwwxyMiKi18lcvT1fjIFy8Bw2xPvv5/KK+j7evTjwNh3Wjmi+jWRg2xxlhEWXk2NAydSCiWVCSeAwp5/RKcoBwrWBl4lYFIC/EeCzik+CYwrExatJ1SpLCLcwFQphCKqLqs5Z/HDEw2X73ZJWPkLZIHTCRnJOMs4Ykybn+xl4/plnUtlHFbdnJFrC7+CezfR5whQ2mHlnQ3SbO/S1PbWD+ItwoztbDfFRthYFrYiOi1J+bLcc1llQ6V69/0hou44fLDVtZy3JQysdC8I6Uty7uyNATKDT/K4Cd/qlpSe0SQOYQ8GnuwlKMoYG7YHJ6uFkksOAPhfNbzaiLWtI+pdex3e94qEHo0otiksXQpWlGKJtLaSe39wSB/RN057npi/387qv8OHL/kxU8UpikHsQGTHI98kOxiaA+nLJMVlCUVa7hPUUthVVfZWBZG04chA0/ywurIWWdwim7hamI9XJQoPE6wnV9uSX5FsWisbnrgOqIXqMWNGJYE+wuZ05gwJar1thmKjs9NLJEYLOl/oVTCcpGiwHnKUoJWY+2j+OZ/Rpe8bw+amYi8TPn/pxN3MGter62uD2UJJM/WWBoZH0WCKSyb0p+8WbyBud3vc3qxpHohjyOwehWf9chTU0CE2buKgw9mJKHfDocPWEW3aklazTWSe2bsoAV4N26OLmEmZSLbz3Lhn2RGnJ0vfnP0VA+zmHNufjEhpmKtmM4RtkYs2dnlDbUUl+u4lQsP/byTVYqhJJIhxI69euZYnbPS5xJVkzBOFQtv+xW56zTbn3R+1H1S8/vOpj0xqSjCesoqzTsh/BB2lDC1wBp/qVKOGCrqqvSCho3yXO12/z3Zy7sQVe1Ht9xK7hzoxXZno76b/Sx/E+oCMoSpRPtVcrkiK4jeitQXMCCVcQDHPKUXb0tqvQ+0trUXQ+FGWUbcOVvRpeLo9oD4YkqvbMsnrAs4S0G+p8XaMlxyoWBEdSFN351ERYMWjSezGYYhCDxWsNZZu2Vgzvw7SNljkBujxv/wZD/ptS2d7ajUiLrrFvmnjjNA1wEZI0CerY/hGF6QHwMXfrBEVcgVQm6hDY0yQ9LSXVQt4ACQJtM8CRNCdH3jqCHh+AanpdeTdyGvmQAh0gu6/c9S1EocVI6adrwEgN5OQO5Dsn4SBvJrkK00YvJak/oJHQ7P3EQXGVQVInaDEbj59FEDBfYfsNXt70j73Tm/9gUtfaXXuoa/AjDOusDh/0dWVD0Q0jlcBrnUCI+B1iKdlvzrqjX+tLB5hNyqi6JmESez8IKQULd3a1Z5Vww+lZCSgXV+bDTiLY6ko51ieo1XAv86YgdTOmOuu8o3Ihm3W59IqERf6MCslU4iWSr1ullL42w3Eb2X5HVcevtBzI49p0hNziLjMdgWUOncxk5Aw3w7uqUnRipdIYX274YyOYOEeBwgFkHcNzPNGEKFyg+O9w9zn0bjMP1knUEUpax1IXsZ9UcXNC++ED9LLXsKcmo9qZvN5KtoGaeVNkI+a34lIWeB38TkL7RFXviciDS4SF7w6NfEqyVHWmDxTYdI3mcsAZmuupV0xENWzwSels9h6CmxpcabHRw+NSj+Ya9PsTJhP6kX2wCPVsrfqwyhFEeZYTGpsaMbOIQKNw1BLgV8QUbrUoUrl+J3e3473MgrUORcuD16FTOWV2bIFJE2wLmpN/mSNp3xEU7l41P6NLRYRiCQOJdAisx+wC5zkJt6kWRSDJ7vnrepcJDcc9brdAIC3xSfWXtEnpvnG+Sge0ikG9Ys9bSy4pmod9v9HUDqwKKuNud5VOW5TOICxKOZ4sli8QaFuzH6v1tmxS+jVQ0AzPoCsiItnlyIrsYHKmJ4uGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2726,"cache_read_input_tokens":89050,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2726},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"5c2781a0-8113-4f38-8cf2-1c65b516f1cb"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01TbPPpoqPTPZS6AeFBBfegB","type":"message","role":"assistant","content":[{"type":"text","text":"Now I have a complete picture. Let me implement all changes. I'll do production code first, then spec files."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2726,"cache_read_input_tokens":89050,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2726},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"40f219f3-53a5-4453-b06e-94df56b98b26"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01TbPPpoqPTPZS6AeFBBfegB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017d4iHwtX4pa5PPG4GhyqLL","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/database/entities/task.entity.ts","old_string":"  @Column({ name: 'phase_execute', default: 'pending' })\n  phaseExecute!: string;\n\n  @Column({ name: 'phase_review', default: 'pending' })","new_string":"  @Column({ name: 'phase_review', default: 'pending' })"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2726,"cache_read_input_tokens":89050,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2726},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"e87a1622-78b6-4512-b143-3b0abfb76648"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017d4iHwtX4pa5PPG4GhyqLL","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/database/entities/task.entity.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"8ee6bd2b-1ec1-4380-9a56-e41f8e07f226","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/database/entities/task.entity.ts","oldString":"  @Column({ name: 'phase_execute', default: 'pending' })\n  phaseExecute!: string;\n\n  @Column({ name: 'phase_review', default: 'pending' })","newString":"  @Column({ name: 'phase_review', default: 'pending' })","originalFile":"import {\n  Entity,\n  PrimaryGeneratedColumn,\n  Column,\n  CreateDateColumn,\n  UpdateDateColumn,\n  Unique,\n} from 'typeorm';\n\n@Entity('tasks')\n@Unique(['owner', 'issue', 'repo'])\nexport class TaskEntity {\n  @PrimaryGeneratedColumn()\n  id!: number;\n\n  @Column({ default: 'AgentSDE' })\n  owner!: string;\n\n  @Column()\n  issue!: number;\n\n  @Column()\n  repo!: string;\n\n  @Column({ default: 'auto' })\n  directive!: string;\n\n  @Column({ name: 'current_phase', default: 'refine' })\n  currentPhase!: string;\n\n  @Column({ default: 'active' })\n  status!: string;\n\n  @Column({ name: 'gate_phase', type: 'varchar', nullable: true })\n  gatePhase!: string | null;\n\n  @Column({ name: 'gate_reason', type: 'varchar', nullable: true })\n  gateReason!: string | null;\n\n  @Column({ name: 'pr_number', type: 'integer', nullable: true })\n  prNumber!: number | null;\n\n  @Column({ name: 'pr_branch', type: 'varchar', nullable: true })\n  prBranch!: string | null;\n\n  @Column({ name: 'phase_refine', default: 'pending' })\n  phaseRefine!: string;\n\n  @Column({ name: 'phase_plan', default: 'pending' })\n  phasePlan!: string;\n\n  @Column({ name: 'phase_execute', default: 'pending' })\n  phaseExecute!: string;\n\n  @Column({ name: 'phase_review', default: 'pending' })\n  phaseReview!: string;\n\n  @Column({ name: 'phase_address_review', default: 'pending' })\n  phaseAddressReview!: string;\n\n  @Column({ name: 'phase_deliver', default: 'pending' })\n  phaseDeliver!: string;\n\n  @Column({ name: 'phase_compound', default: 'pending' })\n  phaseCompound!: string;\n\n  @Column({ name: 'quality_gate_override', default: false })\n  qualityGateOverride!: boolean;\n\n  @Column({ name: 'replan_attempt', type: 'integer', default: 0 })\n  replanAttempt!: number;\n\n  @Column({ name: 'started_at', type: 'datetime', nullable: true })\n  startedAt!: Date | null;\n\n  @Column({ name: 'completed_at', type: 'datetime', nullable: true })\n  completedAt!: Date | null;\n\n  @CreateDateColumn({ name: 'created_at' })\n  createdAt!: Date;\n\n  @UpdateDateColumn({ name: 'updated_at' })\n  updatedAt!: Date;\n}\n","structuredPatch":[{"oldStart":49,"oldLines":9,"newStart":49,"newLines":6,"lines":["   @Column({ name: 'phase_plan', default: 'pending' })","   phasePlan!: string;"," ","-  @Column({ name: 'phase_execute', default: 'pending' })","-  phaseExecute!: string;","-","   @Column({ name: 'phase_review', default: 'pending' })","   phaseReview!: string;"," "]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01TbPPpoqPTPZS6AeFBBfegB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CDAdzaHi5sL8YQ6UDwizE5","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/task-state/task-state.service.ts","old_string":"      phaseRefine: 'pending',\n      phasePlan: 'pending',\n      phaseExecute: 'pending',\n      phaseReview: 'pending',","new_string":"      phaseRefine: 'pending',\n      phasePlan: 'pending',\n      phaseReview: 'pending',"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2726,"cache_read_input_tokens":89050,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2726},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"3f999b7a-b897-405e-99b1-6c05cc580cb7"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CDAdzaHi5sL8YQ6UDwizE5","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/task-state/task-state.service.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"a14d72ae-d130-4fcc-9731-02f7eb49f7bf","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/task-state/task-state.service.ts","oldString":"      phaseRefine: 'pending',\n      phasePlan: 'pending',\n      phaseExecute: 'pending',\n      phaseReview: 'pending',","newString":"      phaseRefine: 'pending',\n      phasePlan: 'pending',\n      phaseReview: 'pending',","originalFile":"import { Injectable, Logger } from '@nestjs/common';\nimport { InjectRepository } from '@nestjs/typeorm';\nimport { In, Repository } from 'typeorm';\nimport { EventBusService } from '../event-bus';\nimport { TaskEntity } from '../database/entities';\nimport { TenantConfigService } from '../tenant/tenant-config.service';\n\n@Injectable()\nexport class TaskStateService {\n  private readonly logger = new Logger(TaskStateService.name);\n\n  constructor(\n    @InjectRepository(TaskEntity)\n    private readonly taskRepo: Repository<TaskEntity>,\n    private readonly eventBus: EventBusService,\n    private readonly tenantConfig: TenantConfigService,\n  ) {}\n\n  async findByIssueAndRepo(\n    issue: number,\n    repo: string,\n  ): Promise<TaskEntity | null> {\n    const owner = this.tenantConfig.getConfig().org;\n    return this.taskRepo.findOne({ where: { owner, issue, repo } });\n  }\n\n  async createTask(\n    issue: number,\n    repo: string,\n    directive: string = 'auto',\n  ): Promise<TaskEntity> {\n    const owner = this.tenantConfig.getConfig().org;\n    const task = this.taskRepo.create({\n      owner,\n      issue,\n      repo,\n      directive,\n      currentPhase: 'refine',\n      status: 'active',\n      phaseRefine: 'pending',\n      phasePlan: 'pending',\n      phaseExecute: 'pending',\n      phaseReview: 'pending',\n      phaseAddressReview: 'pending',\n      phaseDeliver: 'pending',\n      phaseCompound: 'pending',\n    });\n    // New task with status 'active' — set startedAt\n    if (task.startedAt === null || task.startedAt === undefined) {\n      task.startedAt = new Date();\n    }\n    const saved = await this.taskRepo.save(task);\n    try {\n      this.eventBus.emit('task.updated', {\n        taskId: saved.id,\n        issue: saved.issue,\n        repo: saved.repo,\n        status: saved.status,\n        currentPhase: saved.currentPhase,\n        gatePhase: saved.gatePhase ?? null,\n        gateReason: saved.gateReason ?? null,\n      });\n    } catch (err) {\n      this.logger.error(\n        `Failed to emit task.updated: ${(err as Error).message}`,\n      );\n    }\n    return saved;\n  }\n\n  async save(task: TaskEntity): Promise<TaskEntity> {\n    const newStatus = task.status;\n\n    // Look up the previous status from DB (if task has an id)\n    let previousStatus: string | undefined;\n    if (task.id) {\n      const existing = await this.taskRepo.findOne({ where: { id: task.id } });\n      previousStatus = existing?.status;\n    }\n\n    if (newStatus === 'active' && previousStatus !== 'active') {\n      if (task.startedAt === null || task.startedAt === undefined) {\n        task.startedAt = new Date();\n      }\n    }\n\n    if (newStatus === 'complete' || newStatus === 'failed') {\n      if (previousStatus !== newStatus) {\n        if (task.completedAt === null || task.completedAt === undefined) {\n          task.completedAt = new Date();\n        }\n      }\n    }\n\n    const saved = await this.taskRepo.save(task);\n\n    try {\n      this.eventBus.emit('task.updated', {\n        taskId: saved.id,\n        issue: saved.issue,\n        repo: saved.repo,\n        status: saved.status,\n        currentPhase: saved.currentPhase,\n        gatePhase: saved.gatePhase ?? null,\n        gateReason: saved.gateReason ?? null,\n      });\n    } catch (err) {\n      this.logger.error(\n        `Failed to emit task.updated: ${(err as Error).message}`,\n      );\n    }\n\n    return saved;\n  }\n\n  async findByStatus(status: string): Promise<TaskEntity[]> {\n    const owner = this.tenantConfig.getConfig().org;\n    return this.taskRepo.find({ where: { owner, status } });\n  }\n\n  async findByStatuses(statuses: string[]): Promise<TaskEntity[]> {\n    const owner = this.tenantConfig.getConfig().org;\n    return this.taskRepo.find({ where: { owner, status: In(statuses) } });\n  }\n\n  async findAll(): Promise<TaskEntity[]> {\n    const owner = this.tenantConfig.getConfig().org;\n    return this.taskRepo.find({ where: { owner } });\n  }\n\n  private phaseKey(phase: string): keyof TaskEntity {\n    const map: Record<string, keyof TaskEntity> = {\n      refine: 'phaseRefine',\n      plan: 'phasePlan',\n      review: 'phaseReview',\n      address_review: 'phaseAddressReview',\n      deliver: 'phaseDeliver',\n      compound: 'phaseCompound',\n    };\n    const key = map[phase];\n    if (!key) throw new Error(`Unknown phase: ${phase}`);\n    return key;\n  }\n\n  getPhaseStatus(task: TaskEntity, phase: string): string {\n    return task[this.phaseKey(phase)] as string;\n  }\n\n  /**\n   * Update a single phase column and emit a task.updated event.\n   */\n  async updatePhaseStatus(\n    taskId: number,\n    phase: string,\n    status: string,\n  ): Promise<void> {\n    await this.taskRepo.update(taskId, {\n      [this.phaseKey(phase)]: status,\n    });\n    const task = await this.taskRepo.findOneByOrFail({ id: taskId });\n    try {\n      this.eventBus.emit('task.updated', {\n        taskId: task.id,\n        issue: task.issue,\n        repo: task.repo,\n        status: task.status,\n        currentPhase: task.currentPhase,\n        gatePhase: task.gatePhase ?? null,\n        gateReason: task.gateReason ?? null,\n      });\n    } catch (err) {\n      this.logger.error(\n        `Failed to emit task.updated: ${(err as Error).message}`,\n      );\n    }\n  }\n\n  /**\n   * Update arbitrary task fields, handle timestamp logic, and emit event.\n   */\n  async updateTaskStatus(\n    taskId: number,\n    updates: Partial<TaskEntity>,\n  ): Promise<void> {\n    // If status is transitioning to a terminal state, set completedAt\n    if (updates.status === 'complete' || updates.status === 'failed') {\n      const existing = await this.taskRepo.findOneByOrFail({ id: taskId });\n      if (\n        existing.status !== updates.status &&\n        (existing.completedAt === null || existing.completedAt === undefined)\n      ) {\n        updates = {\n          ...updates,\n          completedAt: new Date(),\n        } as Partial<TaskEntity>;\n      }\n    }\n    await this.taskRepo.update(taskId, updates);\n    const task = await this.taskRepo.findOneByOrFail({ id: taskId });\n    try {\n      this.eventBus.emit('task.updated', {\n        taskId: task.id,\n        issue: task.issue,\n        repo: task.repo,\n        status: task.status,\n        currentPhase: task.currentPhase,\n        gatePhase: task.gatePhase ?? null,\n        gateReason: task.gateReason ?? null,\n      });\n    } catch (err) {\n      this.logger.error(\n        `Failed to emit task.updated: ${(err as Error).message}`,\n      );\n    }\n  }\n\n  /**\n   * Mark a task as complete, set completedAt, and emit event.\n   */\n  async markComplete(taskId: number): Promise<void> {\n    await this.updateTaskStatus(taskId, { status: 'complete' });\n  }\n\n  async updatePrMetadata(\n    taskId: number,\n    prNumber: number,\n    prBranch?: string | null,\n  ): Promise<void> {\n    await this.taskRepo.update(taskId, {\n      prNumber,\n      ...(prBranch != null ? { prBranch } : {}),\n    });\n  }\n\n  setPhaseStatus(task: TaskEntity, phase: string, status: string): void {\n    (task as unknown as Record<string, unknown>)[this.phaseKey(phase)] = status;\n  }\n\n  setGate(task: TaskEntity, phase: string, reason: string): void {\n    task.gatePhase = phase;\n    task.gateReason = reason;\n    task.status = 'gated';\n  }\n\n  clearGate(task: TaskEntity): void {\n    task.gatePhase = null;\n    task.gateReason = null;\n  }\n\n  setQualityGateOverride(task: TaskEntity, value: boolean): void {\n    task.qualityGateOverride = value;\n  }\n\n  /**\n   * Reset state for a retry: set the current phase status back to pending\n   * and mark the task as active so it can be re-executed.\n   */\n  resetForRetry(task: TaskEntity): void {\n    if (task.currentPhase) {\n      this.setPhaseStatus(task, task.currentPhase, 'pending');\n    }\n    task.status = 'active';\n  }\n\n  /**\n   * Reset state for a full restart: clear all phase statuses, reset\n   * currentPhase to refine, and mark the task as active.\n   */\n  resetForRestart(task: TaskEntity): void {\n    this.setPhaseStatus(task, 'refine', 'pending');\n    this.setPhaseStatus(task, 'plan', 'pending');\n    this.setPhaseStatus(task, 'deliver', 'pending');\n    this.setPhaseStatus(task, 'review', 'pending');\n    this.setPhaseStatus(task, 'compound', 'pending');\n    task.currentPhase = 'refine';\n    task.status = 'active';\n  }\n\n  /**\n   * Reset state for a replan: resets all phases (via resetForRestart),\n   * nullifies prNumber/prBranch, and increments replanAttempt.\n   */\n  resetForReplan(task: TaskEntity): void {\n    this.resetForRestart(task);\n    task.prNumber = null;\n    task.prBranch = null;\n    task.replanAttempt = (task.replanAttempt ?? 0) + 1;\n  }\n}\n","structuredPatch":[{"oldStart":39,"oldLines":7,"newStart":39,"newLines":6,"lines":["       status: 'active',","       phaseRefine: 'pending',","       phasePlan: 'pending',","-      phaseExecute: 'pending',","       phaseReview: 'pending',","       phaseAddressReview: 'pending',","       phaseDeliver: 'pending',"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01TbPPpoqPTPZS6AeFBBfegB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017YEpuNzG6gxTGsXy36K1EQ","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/control-api/dto/phase-override.dto.ts","old_string":"const PHASES = [\n  'refine',\n  'plan',\n  'execute',\n  'review',\n  'deliver',\n  'compound',\n] as const;","new_string":"const PHASES = [\n  'refine',\n  'plan',\n  'deliver',\n  'review',\n  'compound',\n] as const;"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2726,"cache_read_input_tokens":89050,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2726},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"02d319bc-364a-4ef4-b7eb-86446a093374"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017YEpuNzG6gxTGsXy36K1EQ","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/control-api/dto/phase-override.dto.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"aa2896d5-7d09-4336-aaf0-1188d95ee9a8","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/control-api/dto/phase-override.dto.ts","oldString":"const PHASES = [\n  'refine',\n  'plan',\n  'execute',\n  'review',\n  'deliver',\n  'compound',\n] as const;","newString":"const PHASES = [\n  'refine',\n  'plan',\n  'deliver',\n  'review',\n  'compound',\n] as const;","originalFile":"import { IsIn, IsString } from 'class-validator';\n\nconst PHASES = [\n  'refine',\n  'plan',\n  'execute',\n  'review',\n  'deliver',\n  'compound',\n] as const;\nconst STATUSES = ['pending', 'active', 'complete', 'skipped'] as const;\n\nexport class PhaseOverrideDto {\n  @IsString()\n  @IsIn(PHASES)\n  phase!: string;\n\n  @IsString()\n  @IsIn(STATUSES)\n  status!: string;\n}\n","structuredPatch":[{"oldStart":3,"oldLines":9,"newStart":3,"newLines":8,"lines":[" const PHASES = [","   'refine',","   'plan',","-  'execute',","-  'review',","   'deliver',","+  'review',","   'compound',"," ] as const;"," const STATUSES = ['pending', 'active', 'complete', 'skipped'] as const;"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01TbPPpoqPTPZS6AeFBBfegB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01QTyQP6rx8VzDN5EYeJeXXQ","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/control-api/dto/status-override.dto.ts","old_string":"const PHASES = [\n  'refine',\n  'plan',\n  'execute',\n  'review',\n  'deliver',\n  'compound',\n] as const;","new_string":"const PHASES = [\n  'refine',\n  'plan',\n  'deliver',\n  'review',\n  'compound',\n] as const;"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2726,"cache_read_input_tokens":89050,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2726},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"41245c64-0ab7-4c20-bb96-061d20f96c54"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01QTyQP6rx8VzDN5EYeJeXXQ","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/control-api/dto/status-override.dto.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"de2a26b4-d4db-49f3-b1ee-42bb21113c12","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/control-api/dto/status-override.dto.ts","oldString":"const PHASES = [\n  'refine',\n  'plan',\n  'execute',\n  'review',\n  'deliver',\n  'compound',\n] as const;","newString":"const PHASES = [\n  'refine',\n  'plan',\n  'deliver',\n  'review',\n  'compound',\n] as const;","originalFile":"import { IsIn, IsOptional, IsString } from 'class-validator';\n\nconst STATUSES = [\n  'active',\n  'complete',\n  'failed',\n  'stuck',\n  'paused',\n  'stopped',\n  'gated',\n  'blocked',\n] as const;\nconst PHASES = [\n  'refine',\n  'plan',\n  'execute',\n  'review',\n  'deliver',\n  'compound',\n] as const;\n\nexport class StatusOverrideDto {\n  @IsString()\n  @IsIn(STATUSES)\n  status!: string;\n\n  @IsOptional()\n  @IsString()\n  @IsIn(PHASES)\n  currentPhase?: string;\n}\n","structuredPatch":[{"oldStart":13,"oldLines":9,"newStart":13,"newLines":8,"lines":[" const PHASES = [","   'refine',","   'plan',","-  'execute',","-  'review',","   'deliver',","+  'review',","   'compound',"," ] as const;"," "]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_015wT6D5AqgugoxjiqPknmDj","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the spec files:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":4700,"cache_read_input_tokens":91776,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4700},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"0aa73db6-07c1-4cc4-8674-e496166c7523"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_015wT6D5AqgugoxjiqPknmDj","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01YcDsGLL13h8pCao9KgcQ84","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/database/database.module.spec.ts","old_string":"    expect(saved.phaseRefine).toBe('pending');\n    expect(saved.phasePlan).toBe('pending');\n    expect(saved.phaseExecute).toBe('pending');\n    expect(saved.phaseReview).toBe('pending');","new_string":"    expect(saved.phaseRefine).toBe('pending');\n    expect(saved.phasePlan).toBe('pending');\n    expect(saved.phaseReview).toBe('pending');"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":4700,"cache_read_input_tokens":91776,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4700},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"e23d5040-13f6-48f7-aabd-5524f9eb041e"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01YcDsGLL13h8pCao9KgcQ84","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/database/database.module.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"db275f19-2b8e-4f70-8c07-d9c31979c0d1","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/database/database.module.spec.ts","oldString":"    expect(saved.phaseRefine).toBe('pending');\n    expect(saved.phasePlan).toBe('pending');\n    expect(saved.phaseExecute).toBe('pending');\n    expect(saved.phaseReview).toBe('pending');","newString":"    expect(saved.phaseRefine).toBe('pending');\n    expect(saved.phasePlan).toBe('pending');\n    expect(saved.phaseReview).toBe('pending');","originalFile":"import { Test, TestingModule } from '@nestjs/testing';\nimport { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm';\nimport { Repository } from 'typeorm';\nimport { TaskEntity } from './entities/task.entity';\nimport { EventEntity } from './entities/event.entity';\nimport { WebhookDeliveryEntity } from './entities/webhook-delivery.entity';\nimport { RuntimeConfigEntity } from './entities/runtime-config.entity';\n\ndescribe('DatabaseModule', () => {\n  let module: TestingModule;\n  let taskRepo: Repository<TaskEntity>;\n  let eventRepo: Repository<EventEntity>;\n  let webhookDeliveryRepo: Repository<WebhookDeliveryEntity>;\n  let runtimeConfigRepo: Repository<RuntimeConfigEntity>;\n\n  beforeAll(async () => {\n    module = await Test.createTestingModule({\n      imports: [\n        TypeOrmModule.forRoot({\n          type: 'better-sqlite3',\n          database: ':memory:',\n          entities: [\n            TaskEntity,\n            EventEntity,\n            WebhookDeliveryEntity,\n            RuntimeConfigEntity,\n          ],\n          synchronize: true,\n        }),\n        TypeOrmModule.forFeature([\n          TaskEntity,\n          EventEntity,\n          WebhookDeliveryEntity,\n          RuntimeConfigEntity,\n        ]),\n      ],\n    }).compile();\n\n    taskRepo = module.get<Repository<TaskEntity>>(\n      getRepositoryToken(TaskEntity),\n    );\n    eventRepo = module.get<Repository<EventEntity>>(\n      getRepositoryToken(EventEntity),\n    );\n    webhookDeliveryRepo = module.get<Repository<WebhookDeliveryEntity>>(\n      getRepositoryToken(WebhookDeliveryEntity),\n    );\n    runtimeConfigRepo = module.get<Repository<RuntimeConfigEntity>>(\n      getRepositoryToken(RuntimeConfigEntity),\n    );\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  it('should create a TaskEntity with default values', async () => {\n    const task = taskRepo.create({ issue: 1, repo: 'owner/repo' });\n    const saved = await taskRepo.save(task);\n\n    expect(saved.id).toBeDefined();\n    expect(saved.directive).toBe('auto');\n    expect(saved.currentPhase).toBe('refine');\n    expect(saved.status).toBe('active');\n    expect(saved.phaseRefine).toBe('pending');\n    expect(saved.phasePlan).toBe('pending');\n    expect(saved.phaseExecute).toBe('pending');\n    expect(saved.phaseReview).toBe('pending');\n    expect(saved.phaseDeliver).toBe('pending');\n    expect(saved.phaseCompound).toBe('pending');\n    expect(saved.createdAt).toBeInstanceOf(Date);\n    expect(saved.updatedAt).toBeInstanceOf(Date);\n  });\n\n  it('should enforce UNIQUE constraint on (issue, repo)', async () => {\n    const task1 = taskRepo.create({ issue: 100, repo: 'owner/unique-test' });\n    await taskRepo.save(task1);\n\n    const task2 = taskRepo.create({ issue: 100, repo: 'owner/unique-test' });\n    await expect(taskRepo.save(task2)).rejects.toThrow();\n  });\n\n  it('should allow same issue number in different repos', async () => {\n    const task1 = taskRepo.create({ issue: 200, repo: 'owner/repo-a' });\n    const task2 = taskRepo.create({ issue: 200, repo: 'owner/repo-b' });\n\n    const saved1 = await taskRepo.save(task1);\n    const saved2 = await taskRepo.save(task2);\n\n    expect(saved1.id).not.toBe(saved2.id);\n  });\n\n  it('should create an EventEntity linked to a task', async () => {\n    const task = taskRepo.create({ issue: 300, repo: 'owner/event-test' });\n    const savedTask = await taskRepo.save(task);\n\n    const event = eventRepo.create({\n      taskId: savedTask.id,\n      type: 'phase.transition',\n      source: 'webhook',\n      data: JSON.stringify({ from: 'refine', to: 'plan' }),\n    });\n    const savedEvent = await eventRepo.save(event);\n\n    expect(savedEvent.id).toBeDefined();\n    expect(savedEvent.taskId).toBe(savedTask.id);\n    expect(savedEvent.type).toBe('phase.transition');\n    expect(savedEvent.source).toBe('webhook');\n    expect(savedEvent.createdAt).toBeInstanceOf(Date);\n  });\n\n  it('should handle nullable fields correctly on TaskEntity', async () => {\n    const task = taskRepo.create({ issue: 400, repo: 'owner/nullable-test' });\n    const saved = await taskRepo.save(task);\n\n    expect(saved.gatePhase).toBeNull();\n    expect(saved.gateReason).toBeNull();\n    expect(saved.prNumber).toBeNull();\n    expect(saved.prBranch).toBeNull();\n    expect(saved.startedAt).toBeNull();\n    expect(saved.completedAt).toBeNull();\n  });\n\n  it('should persist startedAt and completedAt on TaskEntity', async () => {\n    const now = new Date();\n    const task = taskRepo.create({\n      issue: 500,\n      repo: 'owner/timestamps-test',\n      startedAt: now,\n      completedAt: now,\n    });\n    const saved = await taskRepo.save(task);\n\n    expect(saved.startedAt).toBeInstanceOf(Date);\n    expect(saved.completedAt).toBeInstanceOf(Date);\n  });\n\n  it('should create a WebhookDeliveryEntity with default status', async () => {\n    const delivery = webhookDeliveryRepo.create({\n      deliveryId: 'delivery-001',\n      source: 'github',\n      eventType: 'issues',\n      payload: JSON.stringify({ action: 'opened' }),\n    });\n    const saved = await webhookDeliveryRepo.save(delivery);\n\n    expect(saved.id).toBeDefined();\n    expect(saved.deliveryId).toBe('delivery-001');\n    expect(saved.source).toBe('github');\n    expect(saved.eventType).toBe('issues');\n    expect(saved.status).toBe('received');\n    expect(saved.headers).toBeNull();\n    expect(saved.receivedAt).toBeInstanceOf(Date);\n  });\n\n  it('should enforce UNIQUE constraint on deliveryId in WebhookDeliveryEntity', async () => {\n    const delivery1 = webhookDeliveryRepo.create({\n      deliveryId: 'delivery-dup',\n      source: 'github',\n      eventType: 'push',\n      payload: '{}',\n    });\n    await webhookDeliveryRepo.save(delivery1);\n\n    const delivery2 = webhookDeliveryRepo.create({\n      deliveryId: 'delivery-dup',\n      source: 'github',\n      eventType: 'push',\n      payload: '{}',\n    });\n    await expect(webhookDeliveryRepo.save(delivery2)).rejects.toThrow();\n  });\n\n  it('should create and update a RuntimeConfigEntity', async () => {\n    const config = runtimeConfigRepo.create({\n      key: 'WATCHDOG_ENABLED',\n      value: 'true',\n    });\n    const saved = await runtimeConfigRepo.save(config);\n\n    expect(saved.key).toBe('WATCHDOG_ENABLED');\n    expect(saved.value).toBe('true');\n    expect(saved.updatedAt).toBeInstanceOf(Date);\n  });\n\n  it('should use key as primary column in RuntimeConfigEntity', async () => {\n    const config = runtimeConfigRepo.create({\n      key: 'SOME_FEATURE',\n      value: '1',\n    });\n    await runtimeConfigRepo.save(config);\n\n    const found = await runtimeConfigRepo.findOne({\n      where: { key: 'SOME_FEATURE' },\n    });\n    expect(found).toBeDefined();\n    expect(found!.key).toBe('SOME_FEATURE');\n  });\n});\n","structuredPatch":[{"oldStart":64,"oldLines":7,"newStart":64,"newLines":6,"lines":["     expect(saved.status).toBe('active');","     expect(saved.phaseRefine).toBe('pending');","     expect(saved.phasePlan).toBe('pending');","-    expect(saved.phaseExecute).toBe('pending');","     expect(saved.phaseReview).toBe('pending');","     expect(saved.phaseDeliver).toBe('pending');","     expect(saved.phaseCompound).toBe('pending');"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_015wT6D5AqgugoxjiqPknmDj","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Jpk8h7URPYiZNZBrVy5MjA","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/task-state/task-state.service.spec.ts","old_string":"      expect(task.phaseRefine).toBe('pending');\n      expect(task.phasePlan).toBe('pending');\n      expect(task.phaseExecute).toBe('pending');\n      expect(task.phaseReview).toBe('pending');","new_string":"      expect(task.phaseRefine).toBe('pending');\n      expect(task.phasePlan).toBe('pending');\n      expect(task.phaseReview).toBe('pending');"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":4700,"cache_read_input_tokens":91776,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4700},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"f643c25a-2b1e-4a05-8fa1-9d064970ed6d"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Jpk8h7URPYiZNZBrVy5MjA","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/task-state/task-state.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"acab37b9-d3cd-40cf-8940-d48d76566292","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/task-state/task-state.service.spec.ts","oldString":"      expect(task.phaseRefine).toBe('pending');\n      expect(task.phasePlan).toBe('pending');\n      expect(task.phaseExecute).toBe('pending');\n      expect(task.phaseReview).toBe('pending');","newString":"      expect(task.phaseRefine).toBe('pending');\n      expect(task.phasePlan).toBe('pending');\n      expect(task.phaseReview).toBe('pending');","originalFile":"import { Test, TestingModule } from '@nestjs/testing';\nimport { TypeOrmModule } from '@nestjs/typeorm';\nimport { TaskEntity } from '../database/entities';\nimport { TaskStateService } from './task-state.service';\nimport { EventBusService } from '../event-bus';\nimport { TenantConfigService } from '../tenant/tenant-config.service';\n\nconst mockTenantConfig = {\n  getConfig: () => ({ org: 'AgentSDE' }),\n};\n\nconst mockEventBus = {\n  emit: jest.fn(),\n  on: jest.fn(),\n};\n\ndescribe('TaskStateService', () => {\n  let service: TaskStateService;\n  let module: TestingModule;\n\n  beforeEach(async () => {\n    jest.clearAllMocks();\n\n    module = await Test.createTestingModule({\n      imports: [\n        TypeOrmModule.forRoot({\n          type: 'better-sqlite3',\n          database: ':memory:',\n          entities: [TaskEntity],\n          synchronize: true,\n        }),\n        TypeOrmModule.forFeature([TaskEntity]),\n      ],\n      providers: [\n        TaskStateService,\n        { provide: EventBusService, useValue: mockEventBus },\n        { provide: TenantConfigService, useValue: mockTenantConfig },\n      ],\n    }).compile();\n\n    service = module.get<TaskStateService>(TaskStateService);\n  });\n\n  afterEach(async () => {\n    await module.close();\n  });\n\n  describe('createTask', () => {\n    it('creates task with correct defaults (all phases pending, status active)', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n\n      expect(task.id).toBeDefined();\n      expect(task.owner).toBe('AgentSDE');\n      expect(task.issue).toBe(42);\n      expect(task.repo).toBe('owner/repo');\n      expect(task.directive).toBe('auto');\n      expect(task.currentPhase).toBe('refine');\n      expect(task.status).toBe('active');\n      expect(task.phaseRefine).toBe('pending');\n      expect(task.phasePlan).toBe('pending');\n      expect(task.phaseExecute).toBe('pending');\n      expect(task.phaseReview).toBe('pending');\n      expect(task.phaseDeliver).toBe('pending');\n      expect(task.phaseCompound).toBe('pending');\n      expect(task.gatePhase).toBeNull();\n      expect(task.gateReason).toBeNull();\n    });\n\n    it('accepts a custom directive', async () => {\n      const task = await service.createTask(42, 'owner/repo', 'careful');\n      expect(task.directive).toBe('careful');\n    });\n  });\n\n  describe('findByIssueAndRepo', () => {\n    it('returns task when it exists', async () => {\n      await service.createTask(42, 'owner/repo');\n      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n      expect(found).not.toBeNull();\n      expect(found!.issue).toBe(42);\n      expect(found!.repo).toBe('owner/repo');\n    });\n\n    it('returns null for non-existent task', async () => {\n      const found = await service.findByIssueAndRepo(999, 'no/repo');\n      expect(found).toBeNull();\n    });\n  });\n\n  describe('save', () => {\n    it('updates an existing task', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      task.status = 'complete';\n      task.currentPhase = 'deliver';\n      const saved = await service.save(task);\n      expect(saved.status).toBe('complete');\n      expect(saved.currentPhase).toBe('deliver');\n      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n      expect(found!.status).toBe('complete');\n      expect(found!.currentPhase).toBe('deliver');\n    });\n  });\n\n  describe('findByStatus', () => {\n    it('returns tasks matching the status', async () => {\n      await service.createTask(1, 'owner/repo-a');\n      const task2 = await service.createTask(2, 'owner/repo-b');\n      task2.status = 'complete';\n      await service.save(task2);\n      const active = await service.findByStatus('active');\n      const complete = await service.findByStatus('complete');\n      expect(active).toHaveLength(1);\n      expect(active[0].issue).toBe(1);\n      expect(complete).toHaveLength(1);\n      expect(complete[0].issue).toBe(2);\n    });\n  });\n\n  describe('findAll', () => {\n    it('returns all tasks', async () => {\n      await service.createTask(1, 'owner/repo-a');\n      await service.createTask(2, 'owner/repo-b');\n      const all = await service.findAll();\n      expect(all).toHaveLength(2);\n    });\n  });\n\n  describe('getPhaseStatus / setPhaseStatus', () => {\n    const phases = ['refine', 'plan', 'deliver', 'review', 'compound'];\n\n    it.each(phases)('gets and sets phase status for %s', async (phase) => {\n      const task = await service.createTask(42, 'owner/repo');\n      expect(service.getPhaseStatus(task, phase)).toBe('pending');\n      service.setPhaseStatus(task, phase, 'in_progress');\n      expect(service.getPhaseStatus(task, phase)).toBe('in_progress');\n      const saved = await service.save(task);\n      expect(service.getPhaseStatus(saved, phase)).toBe('in_progress');\n    });\n  });\n\n  describe('setGate', () => {\n    it('sets gate fields and status to gated', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      service.setGate(task, 'review', 'Needs human approval');\n      expect(task.gatePhase).toBe('review');\n      expect(task.gateReason).toBe('Needs human approval');\n      expect(task.status).toBe('gated');\n      const saved = await service.save(task);\n      expect(saved.gatePhase).toBe('review');\n      expect(saved.gateReason).toBe('Needs human approval');\n      expect(saved.status).toBe('gated');\n    });\n  });\n\n  describe('clearGate', () => {\n    it('clears gate fields', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      service.setGate(task, 'review', 'Needs approval');\n      await service.save(task);\n      service.clearGate(task);\n      expect(task.gatePhase).toBeNull();\n      expect(task.gateReason).toBeNull();\n      const saved = await service.save(task);\n      expect(saved.gatePhase).toBeNull();\n      expect(saved.gateReason).toBeNull();\n    });\n  });\n\n  describe('unique constraint', () => {\n    it('throws on duplicate (issue, repo)', async () => {\n      await service.createTask(42, 'owner/repo');\n      await expect(service.createTask(42, 'owner/repo')).rejects.toThrow();\n    });\n  });\n\n  describe('task.updated event emission', () => {\n    it('emits task.updated after createTask', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n\n      expect(mockEventBus.emit).toHaveBeenCalledWith(\n        'task.updated',\n        expect.objectContaining({\n          taskId: task.id,\n          issue: 42,\n          repo: 'owner/repo',\n          status: 'active',\n          currentPhase: 'refine',\n        }),\n      );\n    });\n\n    it('emits task.updated after save', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      jest.clearAllMocks();\n\n      task.currentPhase = 'plan';\n      const saved = await service.save(task);\n\n      expect(mockEventBus.emit).toHaveBeenCalledWith(\n        'task.updated',\n        expect.objectContaining({\n          taskId: saved.id,\n          issue: 42,\n          repo: 'owner/repo',\n          status: 'active',\n          currentPhase: 'plan',\n        }),\n      );\n    });\n  });\n\n  describe('startedAt timestamp', () => {\n    it('sets startedAt when creating task with active status', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      expect(task.startedAt).toBeInstanceOf(Date);\n    });\n\n    it('sets startedAt when transitioning from non-active to active', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      // Clear startedAt and set to gated\n      task.startedAt = null;\n      task.status = 'gated';\n      await service.save(task);\n\n      // Now transition back to active\n      task.status = 'active';\n      const saved = await service.save(task);\n      expect(saved.startedAt).toBeInstanceOf(Date);\n    });\n\n    it('does not overwrite startedAt if already set', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      const originalStartedAt = task.startedAt;\n\n      // Set to gated then back to active\n      task.status = 'gated';\n      await service.save(task);\n      task.status = 'active';\n      const saved = await service.save(task);\n\n      expect(saved.startedAt).toEqual(originalStartedAt);\n    });\n  });\n\n  describe('resetForReplan', () => {\n    it('resets all phases, clears PR state, and increments replanAttempt', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      task.prNumber = 99;\n      task.prBranch = 'feat/99-branch';\n      task.replanAttempt = 0;\n      task.currentPhase = 'deliver';\n      service.setPhaseStatus(task, 'refine', 'done');\n      service.setPhaseStatus(task, 'plan', 'done');\n      service.setPhaseStatus(task, 'deliver', 'active');\n\n      service.resetForReplan(task);\n\n      expect(task.prNumber).toBeNull();\n      expect(task.prBranch).toBeNull();\n      expect(task.replanAttempt).toBe(1);\n      expect(task.currentPhase).toBe('refine');\n      expect(task.status).toBe('active');\n      expect(service.getPhaseStatus(task, 'refine')).toBe('pending');\n      expect(service.getPhaseStatus(task, 'plan')).toBe('pending');\n      expect(service.getPhaseStatus(task, 'deliver')).toBe('pending');\n    });\n\n    it('increments replanAttempt on each call (idempotent re-entry)', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      task.replanAttempt = 0;\n\n      service.resetForReplan(task);\n      expect(task.replanAttempt).toBe(1);\n\n      service.resetForReplan(task);\n      expect(task.replanAttempt).toBe(2);\n    });\n\n    it('persists updated replanAttempt after save', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      task.prNumber = 7;\n      task.prBranch = 'feat/7-foo';\n\n      service.resetForReplan(task);\n      const saved = await service.save(task);\n\n      expect(saved.replanAttempt).toBe(1);\n      expect(saved.prNumber).toBeNull();\n      expect(saved.prBranch).toBeNull();\n\n      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n      expect(found!.replanAttempt).toBe(1);\n    });\n  });\n\n  describe('completedAt timestamp', () => {\n    it('sets completedAt when transitioning to complete', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      task.status = 'complete';\n      const saved = await service.save(task);\n      expect(saved.completedAt).toBeInstanceOf(Date);\n    });\n\n    it('sets completedAt when transitioning to failed', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      task.status = 'failed';\n      const saved = await service.save(task);\n      expect(saved.completedAt).toBeInstanceOf(Date);\n    });\n\n    it('does not overwrite completedAt if already set', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      task.status = 'complete';\n      const firstSave = await service.save(task);\n      const originalCompletedAt = firstSave.completedAt;\n\n      // Save again with same status - completedAt should remain unchanged\n      const secondSave = await service.save(firstSave);\n      expect(secondSave.completedAt).toEqual(originalCompletedAt);\n    });\n  });\n\n  describe('updatePhaseStatus', () => {\n    it('updates the phase column and emits task.updated', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      jest.clearAllMocks();\n\n      await service.updatePhaseStatus(task.id, 'refine', 'complete');\n\n      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n      expect(found!.phaseRefine).toBe('complete');\n      expect(mockEventBus.emit).toHaveBeenCalledWith(\n        'task.updated',\n        expect.objectContaining({ taskId: task.id }),\n      );\n    });\n  });\n\n  describe('updateTaskStatus', () => {\n    it('updates arbitrary fields and emits task.updated', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      jest.clearAllMocks();\n\n      await service.updateTaskStatus(task.id, {\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'needs approval',\n      });\n\n      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n      expect(found!.currentPhase).toBe('plan');\n      expect(found!.status).toBe('gated');\n      expect(found!.gatePhase).toBe('plan');\n      expect(mockEventBus.emit).toHaveBeenCalledWith(\n        'task.updated',\n        expect.objectContaining({ taskId: task.id, status: 'gated' }),\n      );\n    });\n\n    it('sets completedAt when transitioning to complete', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n\n      await service.updateTaskStatus(task.id, { status: 'complete' });\n\n      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n      expect(found!.completedAt).toBeInstanceOf(Date);\n    });\n  });\n\n  describe('markComplete', () => {\n    it('sets status to complete with completedAt and emits event', async () => {\n      const task = await service.createTask(42, 'owner/repo');\n      jest.clearAllMocks();\n\n      await service.markComplete(task.id);\n\n      const found = await service.findByIssueAndRepo(42, 'owner/repo');\n      expect(found!.status).toBe('complete');\n      expect(found!.completedAt).toBeInstanceOf(Date);\n      expect(mockEventBus.emit).toHaveBeenCalledWith(\n        'task.updated',\n        expect.objectContaining({ taskId: task.id, status: 'complete' }),\n      );\n    });\n  });\n});\n","structuredPatch":[{"oldStart":58,"oldLines":7,"newStart":58,"newLines":6,"lines":["       expect(task.status).toBe('active');","       expect(task.phaseRefine).toBe('pending');","       expect(task.phasePlan).toBe('pending');","-      expect(task.phaseExecute).toBe('pending');","       expect(task.phaseReview).toBe('pending');","       expect(task.phaseDeliver).toBe('pending');","       expect(task.phaseCompound).toBe('pending');"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01DV1CzpFrZ98Vn4igG4B1jp","type":"message","role":"assistant","content":[{"type":"text","text":"Now update the phase-router spec (multiple changes):"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":763,"cache_read_input_tokens":96476,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":763},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"c3db34fd-aa91-4f08-b768-e835db351c99"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01DV1CzpFrZ98Vn4igG4B1jp","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FTFbbePA6EkhSciA2UUq46","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","old_string":"    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseExecute: 'pending',\n    phaseReview: 'pending',","new_string":"    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseReview: 'pending',"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":763,"cache_read_input_tokens":96476,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":763},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"a32bffdc-290e-43f6-979d-12f2fb19e952"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FTFbbePA6EkhSciA2UUq46","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"f0c7ad22-2f54-47bb-967d-571ff9306daa","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","oldString":"    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseExecute: 'pending',\n    phaseReview: 'pending',","newString":"    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseReview: 'pending',","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { PhaseRouterService } from './phase-router.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\nimport { ClaudeInvocationService } from '../invoke/claude-invocation.service';\nimport { WorktreeService } from '../worktree/worktree.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventService } from '../event/event.service';\nimport { GitHubService } from '../github/github.service';\nimport { TokenUsageService } from '../token-usage/token-usage.service';\nimport { ReleaseService } from '../release/release.service';\nimport { EventBusService } from '../event-bus';\nimport type { TaskEntity } from '../database/entities/task.entity';\nimport type { DispatchEvent } from '../queue/job-queue.interface';\n\nfunction makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n  return {\n    id: 1,\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    currentPhase: 'refine',\n    status: 'active',\n    gatePhase: null,\n    gateReason: null,\n    prNumber: null,\n    prBranch: null,\n    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseExecute: 'pending',\n    phaseReview: 'pending',\n    phaseDeliver: 'pending',\n    phaseCompound: 'pending',\n    qualityGateOverride: false,\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    ...overrides,\n  } as TaskEntity;\n}\n\nfunction makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n  return {\n    type: 'issue_assigned',\n    issueNumber: 42,\n    repo: 'org/repo',\n    ...overrides,\n  };\n}\n\ndescribe('PhaseRouterService', () => {\n  let service: PhaseRouterService;\n  let taskState: jest.Mocked<TaskStateService>;\n  let internalAdapter: jest.Mocked<InternalAdapterService>;\n  let claude: jest.Mocked<ClaudeInvocationService>;\n  let worktreeService: jest.Mocked<WorktreeService>;\n  let artefacts: jest.Mocked<ArtefactsService>;\n  let hooks: jest.Mocked<PhaseHooksService>;\n  let eventService: jest.Mocked<EventService>;\n  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let releaseService: jest.Mocked<\n    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n  >;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        PhaseRouterService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByIssueAndRepo: jest.fn(),\n            createTask: jest.fn(),\n            save: jest.fn(),\n            getPhaseStatus: jest.fn(),\n            setPhaseStatus: jest.fn(),\n            setGate: jest.fn(),\n            clearGate: jest.fn(),\n            resetForRetry: jest.fn(),\n            resetForRestart: jest.fn(),\n            setQualityGateOverride: jest.fn(),\n            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: InternalAdapterService,\n          useValue: {\n            handleSignal: jest.fn(),\n            getNextPhase: jest.fn(),\n          },\n        },\n        {\n          provide: ClaudeInvocationService,\n          useValue: {\n            invoke: jest.fn(),\n          },\n        },\n        {\n          provide: WorktreeService,\n          useValue: {\n            getOrCreateWorktree: jest.fn(),\n            cleanupWorktree: jest.fn(),\n          },\n        },\n        {\n          provide: ArtefactsService,\n          useValue: {\n            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n            buildCentralTaskDir: jest\n              .fn()\n              .mockReturnValue(\n                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n              ),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n            onPhaseComplete: jest.fn(),\n            onPhaseSkip: jest.fn(),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn(),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: GitHubService,\n          useValue: {\n            postComment: jest.fn().mockResolvedValue(undefined),\n            getPRFiles: jest.fn().mockResolvedValue([]),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: TokenUsageService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get(PhaseRouterService);\n    taskState = module.get(TaskStateService);\n    internalAdapter = module.get(InternalAdapterService);\n    claude = module.get(ClaudeInvocationService);\n    worktreeService = module.get(WorktreeService);\n    artefacts = module.get(ArtefactsService);\n    hooks = module.get(PhaseHooksService);\n    eventService = module.get(EventService);\n    gitHub = module.get(GitHubService);\n    eventBus = module.get(EventBusService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  describe('route()', () => {\n    it('should create a new task when none exists and execute the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({\n          ISSUE_NUMBER: '42',\n          REPO: 'org/repo',\n          PHASE: 'refine',\n        }),\n        task.id,\n      );\n    });\n\n    it('should use existing task when found', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should use the directive from the event when creating a task', async () => {\n      const task = makeTask({ directive: 'careful' });\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ directive: 'careful' }));\n\n      expect(taskState.createTask).toHaveBeenCalledWith(\n        42,\n        'org/repo',\n        'careful',\n      );\n    });\n\n    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n      const task = makeTask({ directive: 'careful', currentPhase: 'plan' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      // Plan runs — gate is applied AFTER completion in advanceAndEnqueue, not before\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n      expect(taskState.setGate).not.toHaveBeenCalled();\n    });\n\n    it('should not gate before phase execution (gates are post-completion in internal-adapter)', async () => {\n      const task = makeTask({ directive: 'auto' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should not gate any phase with quick directive', async () => {\n      const task = makeTask({ directive: 'quick' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip when event type cannot be resolved to a phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'unknown_event' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip execution when phase is already active (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should execute phase when phase status is pending (first run)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip completed phase on internal phase_* event (dedup guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip skipped phase on internal phase_* event', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'skipped',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('skipped');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip phase execution when task has been paused by /agent stop', async () => {\n      // Simulate: task.status='paused' was set in DB while a phase_plan\n      // completion event was already in the queue.  The fresh DB read must win.\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'paused',\n        phasePlan: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent stop',\n          actor: 'zhfahan77',\n        }),\n      );\n\n      expect(taskState.save).toHaveBeenCalledWith(\n        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_paused',\n        'phase-router',\n        { actor: 'zhfahan77' },\n      );\n    });\n\n    it('should allow re-executing completed refine on issue_comment event', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_cancelled',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle task_cancelled with no existing task gracefully', async () => {\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(taskState.save).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockRejectedValue(\n        new Error('worktree not found'),\n      );\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      // Should not throw — cleanup failure is best-effort\n    });\n\n    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_reopened',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should clear gate and execute phase on directive event', async () => {\n      const task = makeTask({\n        status: 'gated',\n        directive: 'quick',\n        currentPhase: 'review',\n        gatePhase: 'review',\n        gateReason: 'Awaiting approval',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: 'approve' }),\n      );\n\n      expect(task.status).toBe('active');\n      expect(task.gatePhase).toBeNull();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'gate_released',\n        'phase-router',\n        expect.objectContaining({ directive: 'approve' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({ PHASE: 'review' }),\n        task.id,\n      );\n    });\n  });\n\n  describe('executePhase() via route()', () => {\n    it('should create a worktree for the deliver phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/worktree' }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree for the address_review phase', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix-review' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_address_review' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'address_review',\n        expect.objectContaining({\n          WORKTREE_PATH: '/tmp/worktree',\n          PR_NUMBER: '50',\n          PR_BRANCH: 'feat/fix-review',\n        }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree with agents-md prefix for compound phase', async () => {\n      const task = makeTask({ currentPhase: 'compound' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue(\n        '/tmp/compound-worktree',\n      );\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_compound' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'agents-md',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'compound',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/compound-worktree' }),\n        task.id,\n      );\n    });\n\n    it('should pass rcBranch to getOrCreateWorktree when an active release exists', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue({\n        releaseId: 7,\n        key: 'RC-1',\n        title: 'Release 1',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v1.2',\n        baseBranch: 'master',\n      });\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        'rc/v1.2',\n      );\n    });\n\n    it('should pass undefined baseBranch to getOrCreateWorktree when no release is active', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue(null);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n    });\n\n    it('should not create a worktree for non-execute phases', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(worktreeService.getOrCreateWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree creation failure with transient block', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockRejectedValue(\n        new Error('disk full'),\n      );\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(internalAdapter.handleSignal).toHaveBeenCalledWith(\n        task.id,\n        'deliver',\n        'BLOCKED:TRANSIENT',\n        expect.stringContaining('disk full'),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should set phase status to active before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'plan',\n        'active',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('should include PR env vars when task has prNumber and prBranch', async () => {\n      const task = makeTask({\n        prNumber: 99,\n        prBranch: 'feat/test',\n        directive: 'quick',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_review' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({\n          PR_NUMBER: '99',\n          PR_BRANCH: 'feat/test',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include TASK_DIR env var pointing to central path', async () => {\n      const centralPath =\n        '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42';\n      artefacts.buildCentralTaskDir.mockReturnValue(centralPath);\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(artefacts.buildCentralTaskDir).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_DIR: centralPath }),\n        task.id,\n      );\n    });\n\n    it('should call onPhaseStart before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      const callOrder: string[] = [];\n      hooks.onPhaseStart.mockImplementation((): Promise<void> => {\n        callOrder.push('onPhaseStart');\n        return Promise.resolve();\n      });\n      claude.invoke.mockImplementation((): Promise<void> => {\n        callOrder.push('claude');\n        return Promise.resolve();\n      });\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(hooks.onPhaseStart).toHaveBeenCalledWith(\n        expect.objectContaining({ issue: 42, repo: 'org/repo' }),\n        'refine',\n      );\n      expect(callOrder).toEqual(['onPhaseStart', 'claude']);\n    });\n\n    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n\n      await expect(\n        service.route(makeEvent({ type: 'phase_execute' })),\n      ).rejects.toThrow('Claude crash');\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should not cleanup worktree after normal phase execution', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_execute' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('resolvePhase()', () => {\n    const task = makeTask();\n\n    it('should resolve issue_assigned to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve intake to refine', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n        'refine',\n      );\n    });\n\n    it('should resolve issue_comment to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n          task,\n        ),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve phase_* events to the phase name', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n      ).toBe('refine');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n      ).toBe('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_execute' }), task),\n      ).toBe('execute');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n      ).toBe('deliver');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n      ).toBe('compound');\n    });\n\n    it('should resolve _completed events to the next phase', () => {\n      internalAdapter.getNextPhase.mockReturnValue('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'refine_completed' }), task),\n      ).toBe('plan');\n      expect(internalAdapter.getNextPhase).toHaveBeenCalledWith('refine');\n    });\n\n    it('should resolve pr_merged to compound', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'pr_merged' }), task)).toBe(\n        'compound',\n      );\n    });\n\n    it('should resolve review_approved to null (handled specially in route)', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'review_approved' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve review_changes_requested to address_review', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'review_changes_requested' }),\n          task,\n        ),\n      ).toBe('address_review');\n    });\n\n    it('should resolve ci_passed to deliver', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'ci_passed' }), task)).toBe(\n        'deliver',\n      );\n    });\n\n    it('should resolve ci_failure to deliver', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'ci_failure' }), task),\n      ).toBe('deliver');\n    });\n\n    it('should resolve gate_released to the gated phase', () => {\n      const gatedTask = makeTask({ gatePhase: 'plan' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'gate_released' }), gatedTask),\n      ).toBe('plan');\n    });\n\n    it('should resolve pr_comment to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'pr_comment' }), task),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve task_cancelled to null', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_cancelled' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve task_reopened to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_reopened' }), task),\n      ).toBe('refine');\n    });\n\n    it('should return null for unknown events', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'something_unknown' }), task),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already blocked', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'refine',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), blockedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already gated', () => {\n      const gatedTask = makeTask({\n        status: 'gated',\n        currentPhase: 'review',\n        gatePhase: 'review',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), gatedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already past refine', () => {\n      const planTask = makeTask({ currentPhase: 'plan', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), planTask),\n      ).toBeNull();\n    });\n\n    it('should allow intake when task is still at refine', () => {\n      const refineTask = makeTask({ currentPhase: 'refine', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n      ).toBe('refine');\n    });\n\n    it('should resolve directive to gated phase for gated tasks', () => {\n      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n      ).toBe('review');\n    });\n\n    it('should return null for directive on non-gated tasks', () => {\n      const activeTask = makeTask({ status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n      ).toBeNull();\n    });\n\n    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('execute');\n    });\n\n    it('should resolve retry directive from event.directive field', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'retry' }),\n          stuckTask,\n        ),\n      ).toBe('plan');\n    });\n\n    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n          blockedTask,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve restart directive from event.directive field', () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'restart' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'execute' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via event.directive field', () => {\n      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should return null for retry on task with no currentPhase', () => {\n      const taskNoPhase = makeTask({\n        status: 'active',\n        currentPhase: null as unknown as string,\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          taskNoPhase,\n        ),\n      ).toBeNull();\n    });\n  });\n\n  // isGated() removed — gate logic moved to advanceAndEnqueue() in internal-adapter.\n  // Gate tests are now in internal-adapter.service.spec.ts.\n\n  // signal mapping tests removed — result handling moved to InvocationResultListener.\n\n  describe('issue_comment routing', () => {\n    it('should route issue_comment events to the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should route issue_comment with prNumber to reply_to_pr_comment with COMMENT_ID in env', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PHASE: 'reply_to_pr_comment',\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include PR_NUMBER from event when task has no prNumber', async () => {\n      const task = makeTask({ prNumber: null });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should not include AGENT_PERSONA in env for any phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('AGENT_PERSONA');\n    });\n  });\n\n  describe('review phase status on external review events', () => {\n    it('marks review phase complete when review_approved routes to deliver', async () => {\n      // Use quick directive so deliver is not gated and Claude gets invoked\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('marks review phase complete when review_changes_requested routes to address_review', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // address_review phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'review_changes_requested' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n\n    it('skips review phase status update when review is already complete', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('complete') // review status — already complete\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      // setPhaseStatus should NOT be called for review since it's already complete\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n  });\n\n  describe('event recording', () => {\n    it('should record phase_started event', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'phase_started',\n        'phase-router',\n        { phase: 'refine' },\n      );\n    });\n\n    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n    // after the phase completes, not by phase-router before execution.\n  });\n\n  describe('retry/restart directive handling', () => {\n    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'execute',\n        expect.objectContaining({ PHASE: 'execute' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {\n      const task = makeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n    });\n\n    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent retry',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Retry rejected'),\n      );\n    });\n\n    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent restart',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Restart rejected'),\n      );\n    });\n\n    it('unknown directive: posts feedback comment listing valid directives', async () => {\n      const task = makeTask({ status: 'active' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent foobar',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('retry'),\n      );\n    });\n  });\n\n  describe('quality gate override', () => {\n    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n        task.id,\n      );\n    });\n\n    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n    });\n\n    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        false,\n      );\n    });\n  });\n\n  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n\n  describe('idempotency guards', () => {\n    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'blocked',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'Awaiting approval',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        gatePhase: null,\n        gateReason: null,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent approve',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();\n    });\n\n    // artefact sync tests removed — artefact handling moved to InvocationResultListener.\n\n    it('should ignore intake event when task is already complete (late duplicate guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'compound',\n        status: 'complete',\n        phaseCompound: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    it('calls createTask once and invoke once when route() is called twice with same event', async () => {\n      const task = makeTask();\n      // First call: no task exists → create\n      // Second call: task now exists → reuse\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValue(task);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      const event = makeEvent({ type: 'issue_assigned' });\n      await service.route(event);\n      await service.route(event);\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Second call: phase is still 'pending' so invoke runs again (not prevented by active guard),\n      // but the idempotency contract for external events allows this. The important guarantee is\n      // createTask is not called twice.\n      expect(claude.invoke).toHaveBeenCalledTimes(2);\n    });\n\n    it('falls back to existing task when createTask throws unique-constraint error', async () => {\n      const existingTask = makeTask({ id: 5 });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null) // first call: no task\n        .mockResolvedValue(existingTask); // fallback + subsequent calls (paused check etc.)\n      taskState.createTask.mockRejectedValueOnce(\n        Object.assign(new Error('UNIQUE constraint failed'), {\n          code: 'SQLITE_CONSTRAINT',\n        }),\n      );\n      taskState.save.mockResolvedValue(existingTask);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_assigned' }));\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Falls back to the existing task and continues execution\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_ID: String(existingTask.id) }),\n        existingTask.id,\n      );\n    });\n\n    it('re-throws createTask error when no existing task can be found', async () => {\n      const error = Object.assign(new Error('DB error'), {\n        code: 'SOME_OTHER_ERROR',\n      });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValueOnce(null); // fallback also finds nothing\n      taskState.createTask.mockRejectedValueOnce(error);\n\n      await expect(\n        service.route(makeEvent({ type: 'issue_assigned' })),\n      ).rejects.toThrow('DB error');\n    });\n\n    it('skips phase_ event re-execution when phase is failed and task is gated (BLOCKED:PERSISTENT retry)', async () => {\n      const task = makeTask({\n        status: 'gated',\n        gatePhase: 'deliver',\n        gateReason: 'Max retries exceeded',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n\n      // BullMQ stall-retry re-delivers the phase_deliver job\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('allows phase_ re-execution when phase is failed but task is NOT gated (transient retry)', async () => {\n      // InternalAdapterService sets phase to 'failed' and re-enqueues on transient block;\n      // task.status stays 'active'. This legitimate retry must proceed.\n      const task = makeTask({\n        status: 'active',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n      // Phase status is 'active' in-memory on the task passed into executePhase.\n      // This simulates a scenario where route()'s active-phase check was bypassed.\n      const task = makeTask({\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('calls invoke exactly once when route() processes the same event while phase is active', async () => {\n      const task = makeTask({ phaseRefine: 'pending' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // First getPhaseStatus call (route guard): 'pending' → proceed\n      // executePhase() defensive guard also returns 'pending' → proceed to invoke\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      hooks.onPhaseComplete.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n","structuredPatch":[{"oldStart":30,"oldLines":7,"newStart":30,"newLines":6,"lines":["     prBranch: null,","     phaseRefine: 'pending',","     phasePlan: 'pending',","-    phaseExecute: 'pending',","     phaseReview: 'pending',","     phaseDeliver: 'pending',","     phaseCompound: 'pending',"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01MXrdWifZhrRcLrPGTb92kx","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Pjyb9JfMVAdrX5Tixptmtp","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","old_string":"    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });","new_string":"    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":298,"cache_read_input_tokens":97239,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":298},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"a6888e15-9fec-432e-a72e-da4306e1a8ea"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Pjyb9JfMVAdrX5Tixptmtp","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"f447b387-2470-452a-aae5-f35e2ece801c","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","oldString":"    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });","newString":"    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { PhaseRouterService } from './phase-router.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\nimport { ClaudeInvocationService } from '../invoke/claude-invocation.service';\nimport { WorktreeService } from '../worktree/worktree.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventService } from '../event/event.service';\nimport { GitHubService } from '../github/github.service';\nimport { TokenUsageService } from '../token-usage/token-usage.service';\nimport { ReleaseService } from '../release/release.service';\nimport { EventBusService } from '../event-bus';\nimport type { TaskEntity } from '../database/entities/task.entity';\nimport type { DispatchEvent } from '../queue/job-queue.interface';\n\nfunction makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n  return {\n    id: 1,\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    currentPhase: 'refine',\n    status: 'active',\n    gatePhase: null,\n    gateReason: null,\n    prNumber: null,\n    prBranch: null,\n    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseReview: 'pending',\n    phaseDeliver: 'pending',\n    phaseCompound: 'pending',\n    qualityGateOverride: false,\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    ...overrides,\n  } as TaskEntity;\n}\n\nfunction makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n  return {\n    type: 'issue_assigned',\n    issueNumber: 42,\n    repo: 'org/repo',\n    ...overrides,\n  };\n}\n\ndescribe('PhaseRouterService', () => {\n  let service: PhaseRouterService;\n  let taskState: jest.Mocked<TaskStateService>;\n  let internalAdapter: jest.Mocked<InternalAdapterService>;\n  let claude: jest.Mocked<ClaudeInvocationService>;\n  let worktreeService: jest.Mocked<WorktreeService>;\n  let artefacts: jest.Mocked<ArtefactsService>;\n  let hooks: jest.Mocked<PhaseHooksService>;\n  let eventService: jest.Mocked<EventService>;\n  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let releaseService: jest.Mocked<\n    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n  >;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        PhaseRouterService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByIssueAndRepo: jest.fn(),\n            createTask: jest.fn(),\n            save: jest.fn(),\n            getPhaseStatus: jest.fn(),\n            setPhaseStatus: jest.fn(),\n            setGate: jest.fn(),\n            clearGate: jest.fn(),\n            resetForRetry: jest.fn(),\n            resetForRestart: jest.fn(),\n            setQualityGateOverride: jest.fn(),\n            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: InternalAdapterService,\n          useValue: {\n            handleSignal: jest.fn(),\n            getNextPhase: jest.fn(),\n          },\n        },\n        {\n          provide: ClaudeInvocationService,\n          useValue: {\n            invoke: jest.fn(),\n          },\n        },\n        {\n          provide: WorktreeService,\n          useValue: {\n            getOrCreateWorktree: jest.fn(),\n            cleanupWorktree: jest.fn(),\n          },\n        },\n        {\n          provide: ArtefactsService,\n          useValue: {\n            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n            buildCentralTaskDir: jest\n              .fn()\n              .mockReturnValue(\n                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n              ),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n            onPhaseComplete: jest.fn(),\n            onPhaseSkip: jest.fn(),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn(),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: GitHubService,\n          useValue: {\n            postComment: jest.fn().mockResolvedValue(undefined),\n            getPRFiles: jest.fn().mockResolvedValue([]),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: TokenUsageService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get(PhaseRouterService);\n    taskState = module.get(TaskStateService);\n    internalAdapter = module.get(InternalAdapterService);\n    claude = module.get(ClaudeInvocationService);\n    worktreeService = module.get(WorktreeService);\n    artefacts = module.get(ArtefactsService);\n    hooks = module.get(PhaseHooksService);\n    eventService = module.get(EventService);\n    gitHub = module.get(GitHubService);\n    eventBus = module.get(EventBusService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  describe('route()', () => {\n    it('should create a new task when none exists and execute the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({\n          ISSUE_NUMBER: '42',\n          REPO: 'org/repo',\n          PHASE: 'refine',\n        }),\n        task.id,\n      );\n    });\n\n    it('should use existing task when found', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should use the directive from the event when creating a task', async () => {\n      const task = makeTask({ directive: 'careful' });\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ directive: 'careful' }));\n\n      expect(taskState.createTask).toHaveBeenCalledWith(\n        42,\n        'org/repo',\n        'careful',\n      );\n    });\n\n    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n      const task = makeTask({ directive: 'careful', currentPhase: 'plan' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      // Plan runs — gate is applied AFTER completion in advanceAndEnqueue, not before\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n      expect(taskState.setGate).not.toHaveBeenCalled();\n    });\n\n    it('should not gate before phase execution (gates are post-completion in internal-adapter)', async () => {\n      const task = makeTask({ directive: 'auto' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should not gate any phase with quick directive', async () => {\n      const task = makeTask({ directive: 'quick' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip when event type cannot be resolved to a phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'unknown_event' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip execution when phase is already active (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should execute phase when phase status is pending (first run)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip completed phase on internal phase_* event (dedup guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip skipped phase on internal phase_* event', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'skipped',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('skipped');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip phase execution when task has been paused by /agent stop', async () => {\n      // Simulate: task.status='paused' was set in DB while a phase_plan\n      // completion event was already in the queue.  The fresh DB read must win.\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'paused',\n        phasePlan: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent stop',\n          actor: 'zhfahan77',\n        }),\n      );\n\n      expect(taskState.save).toHaveBeenCalledWith(\n        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_paused',\n        'phase-router',\n        { actor: 'zhfahan77' },\n      );\n    });\n\n    it('should allow re-executing completed refine on issue_comment event', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_cancelled',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle task_cancelled with no existing task gracefully', async () => {\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(taskState.save).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockRejectedValue(\n        new Error('worktree not found'),\n      );\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      // Should not throw — cleanup failure is best-effort\n    });\n\n    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_reopened',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should clear gate and execute phase on directive event', async () => {\n      const task = makeTask({\n        status: 'gated',\n        directive: 'quick',\n        currentPhase: 'review',\n        gatePhase: 'review',\n        gateReason: 'Awaiting approval',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: 'approve' }),\n      );\n\n      expect(task.status).toBe('active');\n      expect(task.gatePhase).toBeNull();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'gate_released',\n        'phase-router',\n        expect.objectContaining({ directive: 'approve' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({ PHASE: 'review' }),\n        task.id,\n      );\n    });\n  });\n\n  describe('executePhase() via route()', () => {\n    it('should create a worktree for the deliver phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/worktree' }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree for the address_review phase', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix-review' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_address_review' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'address_review',\n        expect.objectContaining({\n          WORKTREE_PATH: '/tmp/worktree',\n          PR_NUMBER: '50',\n          PR_BRANCH: 'feat/fix-review',\n        }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree with agents-md prefix for compound phase', async () => {\n      const task = makeTask({ currentPhase: 'compound' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue(\n        '/tmp/compound-worktree',\n      );\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_compound' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'agents-md',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'compound',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/compound-worktree' }),\n        task.id,\n      );\n    });\n\n    it('should pass rcBranch to getOrCreateWorktree when an active release exists', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue({\n        releaseId: 7,\n        key: 'RC-1',\n        title: 'Release 1',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v1.2',\n        baseBranch: 'master',\n      });\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        'rc/v1.2',\n      );\n    });\n\n    it('should pass undefined baseBranch to getOrCreateWorktree when no release is active', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue(null);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n    });\n\n    it('should not create a worktree for non-execute phases', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(worktreeService.getOrCreateWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree creation failure with transient block', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockRejectedValue(\n        new Error('disk full'),\n      );\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(internalAdapter.handleSignal).toHaveBeenCalledWith(\n        task.id,\n        'deliver',\n        'BLOCKED:TRANSIENT',\n        expect.stringContaining('disk full'),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should set phase status to active before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'plan',\n        'active',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('should include PR env vars when task has prNumber and prBranch', async () => {\n      const task = makeTask({\n        prNumber: 99,\n        prBranch: 'feat/test',\n        directive: 'quick',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_review' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({\n          PR_NUMBER: '99',\n          PR_BRANCH: 'feat/test',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include TASK_DIR env var pointing to central path', async () => {\n      const centralPath =\n        '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42';\n      artefacts.buildCentralTaskDir.mockReturnValue(centralPath);\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(artefacts.buildCentralTaskDir).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_DIR: centralPath }),\n        task.id,\n      );\n    });\n\n    it('should call onPhaseStart before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      const callOrder: string[] = [];\n      hooks.onPhaseStart.mockImplementation((): Promise<void> => {\n        callOrder.push('onPhaseStart');\n        return Promise.resolve();\n      });\n      claude.invoke.mockImplementation((): Promise<void> => {\n        callOrder.push('claude');\n        return Promise.resolve();\n      });\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(hooks.onPhaseStart).toHaveBeenCalledWith(\n        expect.objectContaining({ issue: 42, repo: 'org/repo' }),\n        'refine',\n      );\n      expect(callOrder).toEqual(['onPhaseStart', 'claude']);\n    });\n\n    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n\n      await expect(\n        service.route(makeEvent({ type: 'phase_execute' })),\n      ).rejects.toThrow('Claude crash');\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should not cleanup worktree after normal phase execution', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_execute' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('resolvePhase()', () => {\n    const task = makeTask();\n\n    it('should resolve issue_assigned to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve intake to refine', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n        'refine',\n      );\n    });\n\n    it('should resolve issue_comment to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n          task,\n        ),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve phase_* events to the phase name', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n      ).toBe('refine');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n      ).toBe('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_execute' }), task),\n      ).toBe('execute');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n      ).toBe('deliver');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n      ).toBe('compound');\n    });\n\n    it('should resolve _completed events to the next phase', () => {\n      internalAdapter.getNextPhase.mockReturnValue('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'refine_completed' }), task),\n      ).toBe('plan');\n      expect(internalAdapter.getNextPhase).toHaveBeenCalledWith('refine');\n    });\n\n    it('should resolve pr_merged to compound', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'pr_merged' }), task)).toBe(\n        'compound',\n      );\n    });\n\n    it('should resolve review_approved to null (handled specially in route)', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'review_approved' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve review_changes_requested to address_review', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'review_changes_requested' }),\n          task,\n        ),\n      ).toBe('address_review');\n    });\n\n    it('should resolve ci_passed to deliver', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'ci_passed' }), task)).toBe(\n        'deliver',\n      );\n    });\n\n    it('should resolve ci_failure to deliver', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'ci_failure' }), task),\n      ).toBe('deliver');\n    });\n\n    it('should resolve gate_released to the gated phase', () => {\n      const gatedTask = makeTask({ gatePhase: 'plan' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'gate_released' }), gatedTask),\n      ).toBe('plan');\n    });\n\n    it('should resolve pr_comment to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'pr_comment' }), task),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve task_cancelled to null', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_cancelled' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve task_reopened to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_reopened' }), task),\n      ).toBe('refine');\n    });\n\n    it('should return null for unknown events', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'something_unknown' }), task),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already blocked', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'refine',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), blockedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already gated', () => {\n      const gatedTask = makeTask({\n        status: 'gated',\n        currentPhase: 'review',\n        gatePhase: 'review',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), gatedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already past refine', () => {\n      const planTask = makeTask({ currentPhase: 'plan', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), planTask),\n      ).toBeNull();\n    });\n\n    it('should allow intake when task is still at refine', () => {\n      const refineTask = makeTask({ currentPhase: 'refine', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n      ).toBe('refine');\n    });\n\n    it('should resolve directive to gated phase for gated tasks', () => {\n      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n      ).toBe('review');\n    });\n\n    it('should return null for directive on non-gated tasks', () => {\n      const activeTask = makeTask({ status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n      ).toBeNull();\n    });\n\n    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('execute');\n    });\n\n    it('should resolve retry directive from event.directive field', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'retry' }),\n          stuckTask,\n        ),\n      ).toBe('plan');\n    });\n\n    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n          blockedTask,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve restart directive from event.directive field', () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'restart' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'execute' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via event.directive field', () => {\n      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should return null for retry on task with no currentPhase', () => {\n      const taskNoPhase = makeTask({\n        status: 'active',\n        currentPhase: null as unknown as string,\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          taskNoPhase,\n        ),\n      ).toBeNull();\n    });\n  });\n\n  // isGated() removed — gate logic moved to advanceAndEnqueue() in internal-adapter.\n  // Gate tests are now in internal-adapter.service.spec.ts.\n\n  // signal mapping tests removed — result handling moved to InvocationResultListener.\n\n  describe('issue_comment routing', () => {\n    it('should route issue_comment events to the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should route issue_comment with prNumber to reply_to_pr_comment with COMMENT_ID in env', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PHASE: 'reply_to_pr_comment',\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include PR_NUMBER from event when task has no prNumber', async () => {\n      const task = makeTask({ prNumber: null });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should not include AGENT_PERSONA in env for any phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('AGENT_PERSONA');\n    });\n  });\n\n  describe('review phase status on external review events', () => {\n    it('marks review phase complete when review_approved routes to deliver', async () => {\n      // Use quick directive so deliver is not gated and Claude gets invoked\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('marks review phase complete when review_changes_requested routes to address_review', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // address_review phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'review_changes_requested' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n\n    it('skips review phase status update when review is already complete', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('complete') // review status — already complete\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      // setPhaseStatus should NOT be called for review since it's already complete\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n  });\n\n  describe('event recording', () => {\n    it('should record phase_started event', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'phase_started',\n        'phase-router',\n        { phase: 'refine' },\n      );\n    });\n\n    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n    // after the phase completes, not by phase-router before execution.\n  });\n\n  describe('retry/restart directive handling', () => {\n    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'execute',\n        expect.objectContaining({ PHASE: 'execute' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {\n      const task = makeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n    });\n\n    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent retry',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Retry rejected'),\n      );\n    });\n\n    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent restart',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Restart rejected'),\n      );\n    });\n\n    it('unknown directive: posts feedback comment listing valid directives', async () => {\n      const task = makeTask({ status: 'active' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent foobar',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('retry'),\n      );\n    });\n  });\n\n  describe('quality gate override', () => {\n    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n        task.id,\n      );\n    });\n\n    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n    });\n\n    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        false,\n      );\n    });\n  });\n\n  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n\n  describe('idempotency guards', () => {\n    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'blocked',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'Awaiting approval',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        gatePhase: null,\n        gateReason: null,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent approve',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();\n    });\n\n    // artefact sync tests removed — artefact handling moved to InvocationResultListener.\n\n    it('should ignore intake event when task is already complete (late duplicate guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'compound',\n        status: 'complete',\n        phaseCompound: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    it('calls createTask once and invoke once when route() is called twice with same event', async () => {\n      const task = makeTask();\n      // First call: no task exists → create\n      // Second call: task now exists → reuse\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValue(task);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      const event = makeEvent({ type: 'issue_assigned' });\n      await service.route(event);\n      await service.route(event);\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Second call: phase is still 'pending' so invoke runs again (not prevented by active guard),\n      // but the idempotency contract for external events allows this. The important guarantee is\n      // createTask is not called twice.\n      expect(claude.invoke).toHaveBeenCalledTimes(2);\n    });\n\n    it('falls back to existing task when createTask throws unique-constraint error', async () => {\n      const existingTask = makeTask({ id: 5 });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null) // first call: no task\n        .mockResolvedValue(existingTask); // fallback + subsequent calls (paused check etc.)\n      taskState.createTask.mockRejectedValueOnce(\n        Object.assign(new Error('UNIQUE constraint failed'), {\n          code: 'SQLITE_CONSTRAINT',\n        }),\n      );\n      taskState.save.mockResolvedValue(existingTask);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_assigned' }));\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Falls back to the existing task and continues execution\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_ID: String(existingTask.id) }),\n        existingTask.id,\n      );\n    });\n\n    it('re-throws createTask error when no existing task can be found', async () => {\n      const error = Object.assign(new Error('DB error'), {\n        code: 'SOME_OTHER_ERROR',\n      });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValueOnce(null); // fallback also finds nothing\n      taskState.createTask.mockRejectedValueOnce(error);\n\n      await expect(\n        service.route(makeEvent({ type: 'issue_assigned' })),\n      ).rejects.toThrow('DB error');\n    });\n\n    it('skips phase_ event re-execution when phase is failed and task is gated (BLOCKED:PERSISTENT retry)', async () => {\n      const task = makeTask({\n        status: 'gated',\n        gatePhase: 'deliver',\n        gateReason: 'Max retries exceeded',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n\n      // BullMQ stall-retry re-delivers the phase_deliver job\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('allows phase_ re-execution when phase is failed but task is NOT gated (transient retry)', async () => {\n      // InternalAdapterService sets phase to 'failed' and re-enqueues on transient block;\n      // task.status stays 'active'. This legitimate retry must proceed.\n      const task = makeTask({\n        status: 'active',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n      // Phase status is 'active' in-memory on the task passed into executePhase.\n      // This simulates a scenario where route()'s active-phase check was bypassed.\n      const task = makeTask({\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('calls invoke exactly once when route() processes the same event while phase is active', async () => {\n      const task = makeTask({ phaseRefine: 'pending' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // First getPhaseStatus call (route guard): 'pending' → proceed\n      // executePhase() defensive guard also returns 'pending' → proceed to invoke\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      hooks.onPhaseComplete.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n","structuredPatch":[{"oldStart":352,"oldLines":7,"newStart":352,"newLines":7,"lines":["     });"," ","     it('should pause task and return early when /agent stop directive arrives', async () => {","-      const task = makeTask({ status: 'active', currentPhase: 'execute' });","+      const task = makeTask({ status: 'active', currentPhase: 'deliver' });","       taskState.findByIssueAndRepo.mockResolvedValue(task);","       taskState.save.mockResolvedValue(task);"," "]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_018Pksh24PbKC7vAKsV5Hzbv","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01D1kTJBcjTAZhmR3SjvouE8","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","old_string":"    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });","new_string":"    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":297,"cache_read_input_tokens":97537,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":297},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"d96daa03-7ce8-4cd6-b8d4-b32ddfe04522"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01D1kTJBcjTAZhmR3SjvouE8","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"f1ebcf2e-fd04-486f-98d7-671506213d84","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","oldString":"    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });","newString":"    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { PhaseRouterService } from './phase-router.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\nimport { ClaudeInvocationService } from '../invoke/claude-invocation.service';\nimport { WorktreeService } from '../worktree/worktree.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventService } from '../event/event.service';\nimport { GitHubService } from '../github/github.service';\nimport { TokenUsageService } from '../token-usage/token-usage.service';\nimport { ReleaseService } from '../release/release.service';\nimport { EventBusService } from '../event-bus';\nimport type { TaskEntity } from '../database/entities/task.entity';\nimport type { DispatchEvent } from '../queue/job-queue.interface';\n\nfunction makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n  return {\n    id: 1,\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    currentPhase: 'refine',\n    status: 'active',\n    gatePhase: null,\n    gateReason: null,\n    prNumber: null,\n    prBranch: null,\n    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseReview: 'pending',\n    phaseDeliver: 'pending',\n    phaseCompound: 'pending',\n    qualityGateOverride: false,\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    ...overrides,\n  } as TaskEntity;\n}\n\nfunction makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n  return {\n    type: 'issue_assigned',\n    issueNumber: 42,\n    repo: 'org/repo',\n    ...overrides,\n  };\n}\n\ndescribe('PhaseRouterService', () => {\n  let service: PhaseRouterService;\n  let taskState: jest.Mocked<TaskStateService>;\n  let internalAdapter: jest.Mocked<InternalAdapterService>;\n  let claude: jest.Mocked<ClaudeInvocationService>;\n  let worktreeService: jest.Mocked<WorktreeService>;\n  let artefacts: jest.Mocked<ArtefactsService>;\n  let hooks: jest.Mocked<PhaseHooksService>;\n  let eventService: jest.Mocked<EventService>;\n  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let releaseService: jest.Mocked<\n    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n  >;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        PhaseRouterService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByIssueAndRepo: jest.fn(),\n            createTask: jest.fn(),\n            save: jest.fn(),\n            getPhaseStatus: jest.fn(),\n            setPhaseStatus: jest.fn(),\n            setGate: jest.fn(),\n            clearGate: jest.fn(),\n            resetForRetry: jest.fn(),\n            resetForRestart: jest.fn(),\n            setQualityGateOverride: jest.fn(),\n            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: InternalAdapterService,\n          useValue: {\n            handleSignal: jest.fn(),\n            getNextPhase: jest.fn(),\n          },\n        },\n        {\n          provide: ClaudeInvocationService,\n          useValue: {\n            invoke: jest.fn(),\n          },\n        },\n        {\n          provide: WorktreeService,\n          useValue: {\n            getOrCreateWorktree: jest.fn(),\n            cleanupWorktree: jest.fn(),\n          },\n        },\n        {\n          provide: ArtefactsService,\n          useValue: {\n            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n            buildCentralTaskDir: jest\n              .fn()\n              .mockReturnValue(\n                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n              ),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n            onPhaseComplete: jest.fn(),\n            onPhaseSkip: jest.fn(),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn(),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: GitHubService,\n          useValue: {\n            postComment: jest.fn().mockResolvedValue(undefined),\n            getPRFiles: jest.fn().mockResolvedValue([]),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: TokenUsageService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get(PhaseRouterService);\n    taskState = module.get(TaskStateService);\n    internalAdapter = module.get(InternalAdapterService);\n    claude = module.get(ClaudeInvocationService);\n    worktreeService = module.get(WorktreeService);\n    artefacts = module.get(ArtefactsService);\n    hooks = module.get(PhaseHooksService);\n    eventService = module.get(EventService);\n    gitHub = module.get(GitHubService);\n    eventBus = module.get(EventBusService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  describe('route()', () => {\n    it('should create a new task when none exists and execute the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({\n          ISSUE_NUMBER: '42',\n          REPO: 'org/repo',\n          PHASE: 'refine',\n        }),\n        task.id,\n      );\n    });\n\n    it('should use existing task when found', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should use the directive from the event when creating a task', async () => {\n      const task = makeTask({ directive: 'careful' });\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ directive: 'careful' }));\n\n      expect(taskState.createTask).toHaveBeenCalledWith(\n        42,\n        'org/repo',\n        'careful',\n      );\n    });\n\n    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n      const task = makeTask({ directive: 'careful', currentPhase: 'plan' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      // Plan runs — gate is applied AFTER completion in advanceAndEnqueue, not before\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n      expect(taskState.setGate).not.toHaveBeenCalled();\n    });\n\n    it('should not gate before phase execution (gates are post-completion in internal-adapter)', async () => {\n      const task = makeTask({ directive: 'auto' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should not gate any phase with quick directive', async () => {\n      const task = makeTask({ directive: 'quick' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip when event type cannot be resolved to a phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'unknown_event' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip execution when phase is already active (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should execute phase when phase status is pending (first run)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip completed phase on internal phase_* event (dedup guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip skipped phase on internal phase_* event', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'skipped',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('skipped');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip phase execution when task has been paused by /agent stop', async () => {\n      // Simulate: task.status='paused' was set in DB while a phase_plan\n      // completion event was already in the queue.  The fresh DB read must win.\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'paused',\n        phasePlan: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent stop',\n          actor: 'zhfahan77',\n        }),\n      );\n\n      expect(taskState.save).toHaveBeenCalledWith(\n        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_paused',\n        'phase-router',\n        { actor: 'zhfahan77' },\n      );\n    });\n\n    it('should allow re-executing completed refine on issue_comment event', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_cancelled',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle task_cancelled with no existing task gracefully', async () => {\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(taskState.save).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockRejectedValue(\n        new Error('worktree not found'),\n      );\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      // Should not throw — cleanup failure is best-effort\n    });\n\n    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_reopened',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should clear gate and execute phase on directive event', async () => {\n      const task = makeTask({\n        status: 'gated',\n        directive: 'quick',\n        currentPhase: 'review',\n        gatePhase: 'review',\n        gateReason: 'Awaiting approval',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: 'approve' }),\n      );\n\n      expect(task.status).toBe('active');\n      expect(task.gatePhase).toBeNull();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'gate_released',\n        'phase-router',\n        expect.objectContaining({ directive: 'approve' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({ PHASE: 'review' }),\n        task.id,\n      );\n    });\n  });\n\n  describe('executePhase() via route()', () => {\n    it('should create a worktree for the deliver phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/worktree' }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree for the address_review phase', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix-review' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_address_review' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'address_review',\n        expect.objectContaining({\n          WORKTREE_PATH: '/tmp/worktree',\n          PR_NUMBER: '50',\n          PR_BRANCH: 'feat/fix-review',\n        }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree with agents-md prefix for compound phase', async () => {\n      const task = makeTask({ currentPhase: 'compound' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue(\n        '/tmp/compound-worktree',\n      );\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_compound' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'agents-md',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'compound',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/compound-worktree' }),\n        task.id,\n      );\n    });\n\n    it('should pass rcBranch to getOrCreateWorktree when an active release exists', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue({\n        releaseId: 7,\n        key: 'RC-1',\n        title: 'Release 1',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v1.2',\n        baseBranch: 'master',\n      });\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        'rc/v1.2',\n      );\n    });\n\n    it('should pass undefined baseBranch to getOrCreateWorktree when no release is active', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue(null);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n    });\n\n    it('should not create a worktree for non-execute phases', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(worktreeService.getOrCreateWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree creation failure with transient block', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockRejectedValue(\n        new Error('disk full'),\n      );\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(internalAdapter.handleSignal).toHaveBeenCalledWith(\n        task.id,\n        'deliver',\n        'BLOCKED:TRANSIENT',\n        expect.stringContaining('disk full'),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should set phase status to active before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'plan',\n        'active',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('should include PR env vars when task has prNumber and prBranch', async () => {\n      const task = makeTask({\n        prNumber: 99,\n        prBranch: 'feat/test',\n        directive: 'quick',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_review' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({\n          PR_NUMBER: '99',\n          PR_BRANCH: 'feat/test',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include TASK_DIR env var pointing to central path', async () => {\n      const centralPath =\n        '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42';\n      artefacts.buildCentralTaskDir.mockReturnValue(centralPath);\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(artefacts.buildCentralTaskDir).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_DIR: centralPath }),\n        task.id,\n      );\n    });\n\n    it('should call onPhaseStart before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      const callOrder: string[] = [];\n      hooks.onPhaseStart.mockImplementation((): Promise<void> => {\n        callOrder.push('onPhaseStart');\n        return Promise.resolve();\n      });\n      claude.invoke.mockImplementation((): Promise<void> => {\n        callOrder.push('claude');\n        return Promise.resolve();\n      });\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(hooks.onPhaseStart).toHaveBeenCalledWith(\n        expect.objectContaining({ issue: 42, repo: 'org/repo' }),\n        'refine',\n      );\n      expect(callOrder).toEqual(['onPhaseStart', 'claude']);\n    });\n\n    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n\n      await expect(\n        service.route(makeEvent({ type: 'phase_execute' })),\n      ).rejects.toThrow('Claude crash');\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should not cleanup worktree after normal phase execution', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_execute' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('resolvePhase()', () => {\n    const task = makeTask();\n\n    it('should resolve issue_assigned to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve intake to refine', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n        'refine',\n      );\n    });\n\n    it('should resolve issue_comment to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n          task,\n        ),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve phase_* events to the phase name', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n      ).toBe('refine');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n      ).toBe('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_execute' }), task),\n      ).toBe('execute');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n      ).toBe('deliver');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n      ).toBe('compound');\n    });\n\n    it('should resolve _completed events to the next phase', () => {\n      internalAdapter.getNextPhase.mockReturnValue('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'refine_completed' }), task),\n      ).toBe('plan');\n      expect(internalAdapter.getNextPhase).toHaveBeenCalledWith('refine');\n    });\n\n    it('should resolve pr_merged to compound', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'pr_merged' }), task)).toBe(\n        'compound',\n      );\n    });\n\n    it('should resolve review_approved to null (handled specially in route)', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'review_approved' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve review_changes_requested to address_review', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'review_changes_requested' }),\n          task,\n        ),\n      ).toBe('address_review');\n    });\n\n    it('should resolve ci_passed to deliver', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'ci_passed' }), task)).toBe(\n        'deliver',\n      );\n    });\n\n    it('should resolve ci_failure to deliver', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'ci_failure' }), task),\n      ).toBe('deliver');\n    });\n\n    it('should resolve gate_released to the gated phase', () => {\n      const gatedTask = makeTask({ gatePhase: 'plan' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'gate_released' }), gatedTask),\n      ).toBe('plan');\n    });\n\n    it('should resolve pr_comment to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'pr_comment' }), task),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve task_cancelled to null', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_cancelled' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve task_reopened to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_reopened' }), task),\n      ).toBe('refine');\n    });\n\n    it('should return null for unknown events', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'something_unknown' }), task),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already blocked', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'refine',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), blockedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already gated', () => {\n      const gatedTask = makeTask({\n        status: 'gated',\n        currentPhase: 'review',\n        gatePhase: 'review',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), gatedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already past refine', () => {\n      const planTask = makeTask({ currentPhase: 'plan', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), planTask),\n      ).toBeNull();\n    });\n\n    it('should allow intake when task is still at refine', () => {\n      const refineTask = makeTask({ currentPhase: 'refine', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n      ).toBe('refine');\n    });\n\n    it('should resolve directive to gated phase for gated tasks', () => {\n      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n      ).toBe('review');\n    });\n\n    it('should return null for directive on non-gated tasks', () => {\n      const activeTask = makeTask({ status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n      ).toBeNull();\n    });\n\n    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('execute');\n    });\n\n    it('should resolve retry directive from event.directive field', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'retry' }),\n          stuckTask,\n        ),\n      ).toBe('plan');\n    });\n\n    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n          blockedTask,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve restart directive from event.directive field', () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'restart' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'execute' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via event.directive field', () => {\n      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should return null for retry on task with no currentPhase', () => {\n      const taskNoPhase = makeTask({\n        status: 'active',\n        currentPhase: null as unknown as string,\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          taskNoPhase,\n        ),\n      ).toBeNull();\n    });\n  });\n\n  // isGated() removed — gate logic moved to advanceAndEnqueue() in internal-adapter.\n  // Gate tests are now in internal-adapter.service.spec.ts.\n\n  // signal mapping tests removed — result handling moved to InvocationResultListener.\n\n  describe('issue_comment routing', () => {\n    it('should route issue_comment events to the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should route issue_comment with prNumber to reply_to_pr_comment with COMMENT_ID in env', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PHASE: 'reply_to_pr_comment',\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include PR_NUMBER from event when task has no prNumber', async () => {\n      const task = makeTask({ prNumber: null });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should not include AGENT_PERSONA in env for any phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('AGENT_PERSONA');\n    });\n  });\n\n  describe('review phase status on external review events', () => {\n    it('marks review phase complete when review_approved routes to deliver', async () => {\n      // Use quick directive so deliver is not gated and Claude gets invoked\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('marks review phase complete when review_changes_requested routes to address_review', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // address_review phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'review_changes_requested' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n\n    it('skips review phase status update when review is already complete', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('complete') // review status — already complete\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      // setPhaseStatus should NOT be called for review since it's already complete\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n  });\n\n  describe('event recording', () => {\n    it('should record phase_started event', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'phase_started',\n        'phase-router',\n        { phase: 'refine' },\n      );\n    });\n\n    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n    // after the phase completes, not by phase-router before execution.\n  });\n\n  describe('retry/restart directive handling', () => {\n    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'execute',\n        expect.objectContaining({ PHASE: 'execute' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {\n      const task = makeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n    });\n\n    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent retry',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Retry rejected'),\n      );\n    });\n\n    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent restart',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Restart rejected'),\n      );\n    });\n\n    it('unknown directive: posts feedback comment listing valid directives', async () => {\n      const task = makeTask({ status: 'active' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent foobar',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('retry'),\n      );\n    });\n  });\n\n  describe('quality gate override', () => {\n    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n        task.id,\n      );\n    });\n\n    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n    });\n\n    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        false,\n      );\n    });\n  });\n\n  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n\n  describe('idempotency guards', () => {\n    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'blocked',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'Awaiting approval',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        gatePhase: null,\n        gateReason: null,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent approve',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();\n    });\n\n    // artefact sync tests removed — artefact handling moved to InvocationResultListener.\n\n    it('should ignore intake event when task is already complete (late duplicate guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'compound',\n        status: 'complete',\n        phaseCompound: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    it('calls createTask once and invoke once when route() is called twice with same event', async () => {\n      const task = makeTask();\n      // First call: no task exists → create\n      // Second call: task now exists → reuse\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValue(task);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      const event = makeEvent({ type: 'issue_assigned' });\n      await service.route(event);\n      await service.route(event);\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Second call: phase is still 'pending' so invoke runs again (not prevented by active guard),\n      // but the idempotency contract for external events allows this. The important guarantee is\n      // createTask is not called twice.\n      expect(claude.invoke).toHaveBeenCalledTimes(2);\n    });\n\n    it('falls back to existing task when createTask throws unique-constraint error', async () => {\n      const existingTask = makeTask({ id: 5 });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null) // first call: no task\n        .mockResolvedValue(existingTask); // fallback + subsequent calls (paused check etc.)\n      taskState.createTask.mockRejectedValueOnce(\n        Object.assign(new Error('UNIQUE constraint failed'), {\n          code: 'SQLITE_CONSTRAINT',\n        }),\n      );\n      taskState.save.mockResolvedValue(existingTask);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_assigned' }));\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Falls back to the existing task and continues execution\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_ID: String(existingTask.id) }),\n        existingTask.id,\n      );\n    });\n\n    it('re-throws createTask error when no existing task can be found', async () => {\n      const error = Object.assign(new Error('DB error'), {\n        code: 'SOME_OTHER_ERROR',\n      });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValueOnce(null); // fallback also finds nothing\n      taskState.createTask.mockRejectedValueOnce(error);\n\n      await expect(\n        service.route(makeEvent({ type: 'issue_assigned' })),\n      ).rejects.toThrow('DB error');\n    });\n\n    it('skips phase_ event re-execution when phase is failed and task is gated (BLOCKED:PERSISTENT retry)', async () => {\n      const task = makeTask({\n        status: 'gated',\n        gatePhase: 'deliver',\n        gateReason: 'Max retries exceeded',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n\n      // BullMQ stall-retry re-delivers the phase_deliver job\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('allows phase_ re-execution when phase is failed but task is NOT gated (transient retry)', async () => {\n      // InternalAdapterService sets phase to 'failed' and re-enqueues on transient block;\n      // task.status stays 'active'. This legitimate retry must proceed.\n      const task = makeTask({\n        status: 'active',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n      // Phase status is 'active' in-memory on the task passed into executePhase.\n      // This simulates a scenario where route()'s active-phase check was bypassed.\n      const task = makeTask({\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('calls invoke exactly once when route() processes the same event while phase is active', async () => {\n      const task = makeTask({ phaseRefine: 'pending' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // First getPhaseStatus call (route guard): 'pending' → proceed\n      // executePhase() defensive guard also returns 'pending' → proceed to invoke\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      hooks.onPhaseComplete.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n","structuredPatch":[{"oldStart":393,"oldLines":7,"newStart":393,"newLines":7,"lines":["     });"," ","     it('should cancel task on task_cancelled event', async () => {","-      const task = makeTask({ status: 'active', currentPhase: 'execute' });","+      const task = makeTask({ status: 'active', currentPhase: 'deliver' });","       taskState.findByIssueAndRepo.mockResolvedValue(task);","       taskState.save.mockResolvedValue(task);","       worktreeService.cleanupWorktree.mockResolvedValue(undefined);"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_012aWqDfb8i7qHqCj5vEAgGk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AKKRt3cCUFVwjPxFjkaSG3","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","old_string":"    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });","new_string":"    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":289,"cache_read_input_tokens":97834,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":289},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"29c875cf-1aab-410b-bb54-10a17c979810"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AKKRt3cCUFVwjPxFjkaSG3","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"c0fb2d36-47e5-4d45-a2e0-13003fe41bee","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","oldString":"    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });","newString":"    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { PhaseRouterService } from './phase-router.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\nimport { ClaudeInvocationService } from '../invoke/claude-invocation.service';\nimport { WorktreeService } from '../worktree/worktree.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventService } from '../event/event.service';\nimport { GitHubService } from '../github/github.service';\nimport { TokenUsageService } from '../token-usage/token-usage.service';\nimport { ReleaseService } from '../release/release.service';\nimport { EventBusService } from '../event-bus';\nimport type { TaskEntity } from '../database/entities/task.entity';\nimport type { DispatchEvent } from '../queue/job-queue.interface';\n\nfunction makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n  return {\n    id: 1,\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    currentPhase: 'refine',\n    status: 'active',\n    gatePhase: null,\n    gateReason: null,\n    prNumber: null,\n    prBranch: null,\n    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseReview: 'pending',\n    phaseDeliver: 'pending',\n    phaseCompound: 'pending',\n    qualityGateOverride: false,\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    ...overrides,\n  } as TaskEntity;\n}\n\nfunction makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n  return {\n    type: 'issue_assigned',\n    issueNumber: 42,\n    repo: 'org/repo',\n    ...overrides,\n  };\n}\n\ndescribe('PhaseRouterService', () => {\n  let service: PhaseRouterService;\n  let taskState: jest.Mocked<TaskStateService>;\n  let internalAdapter: jest.Mocked<InternalAdapterService>;\n  let claude: jest.Mocked<ClaudeInvocationService>;\n  let worktreeService: jest.Mocked<WorktreeService>;\n  let artefacts: jest.Mocked<ArtefactsService>;\n  let hooks: jest.Mocked<PhaseHooksService>;\n  let eventService: jest.Mocked<EventService>;\n  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let releaseService: jest.Mocked<\n    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n  >;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        PhaseRouterService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByIssueAndRepo: jest.fn(),\n            createTask: jest.fn(),\n            save: jest.fn(),\n            getPhaseStatus: jest.fn(),\n            setPhaseStatus: jest.fn(),\n            setGate: jest.fn(),\n            clearGate: jest.fn(),\n            resetForRetry: jest.fn(),\n            resetForRestart: jest.fn(),\n            setQualityGateOverride: jest.fn(),\n            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: InternalAdapterService,\n          useValue: {\n            handleSignal: jest.fn(),\n            getNextPhase: jest.fn(),\n          },\n        },\n        {\n          provide: ClaudeInvocationService,\n          useValue: {\n            invoke: jest.fn(),\n          },\n        },\n        {\n          provide: WorktreeService,\n          useValue: {\n            getOrCreateWorktree: jest.fn(),\n            cleanupWorktree: jest.fn(),\n          },\n        },\n        {\n          provide: ArtefactsService,\n          useValue: {\n            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n            buildCentralTaskDir: jest\n              .fn()\n              .mockReturnValue(\n                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n              ),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n            onPhaseComplete: jest.fn(),\n            onPhaseSkip: jest.fn(),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn(),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: GitHubService,\n          useValue: {\n            postComment: jest.fn().mockResolvedValue(undefined),\n            getPRFiles: jest.fn().mockResolvedValue([]),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: TokenUsageService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get(PhaseRouterService);\n    taskState = module.get(TaskStateService);\n    internalAdapter = module.get(InternalAdapterService);\n    claude = module.get(ClaudeInvocationService);\n    worktreeService = module.get(WorktreeService);\n    artefacts = module.get(ArtefactsService);\n    hooks = module.get(PhaseHooksService);\n    eventService = module.get(EventService);\n    gitHub = module.get(GitHubService);\n    eventBus = module.get(EventBusService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  describe('route()', () => {\n    it('should create a new task when none exists and execute the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({\n          ISSUE_NUMBER: '42',\n          REPO: 'org/repo',\n          PHASE: 'refine',\n        }),\n        task.id,\n      );\n    });\n\n    it('should use existing task when found', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should use the directive from the event when creating a task', async () => {\n      const task = makeTask({ directive: 'careful' });\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ directive: 'careful' }));\n\n      expect(taskState.createTask).toHaveBeenCalledWith(\n        42,\n        'org/repo',\n        'careful',\n      );\n    });\n\n    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n      const task = makeTask({ directive: 'careful', currentPhase: 'plan' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      // Plan runs — gate is applied AFTER completion in advanceAndEnqueue, not before\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n      expect(taskState.setGate).not.toHaveBeenCalled();\n    });\n\n    it('should not gate before phase execution (gates are post-completion in internal-adapter)', async () => {\n      const task = makeTask({ directive: 'auto' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should not gate any phase with quick directive', async () => {\n      const task = makeTask({ directive: 'quick' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip when event type cannot be resolved to a phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'unknown_event' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip execution when phase is already active (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should execute phase when phase status is pending (first run)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip completed phase on internal phase_* event (dedup guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip skipped phase on internal phase_* event', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'skipped',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('skipped');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip phase execution when task has been paused by /agent stop', async () => {\n      // Simulate: task.status='paused' was set in DB while a phase_plan\n      // completion event was already in the queue.  The fresh DB read must win.\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'paused',\n        phasePlan: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent stop',\n          actor: 'zhfahan77',\n        }),\n      );\n\n      expect(taskState.save).toHaveBeenCalledWith(\n        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_paused',\n        'phase-router',\n        { actor: 'zhfahan77' },\n      );\n    });\n\n    it('should allow re-executing completed refine on issue_comment event', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_cancelled',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle task_cancelled with no existing task gracefully', async () => {\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(taskState.save).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockRejectedValue(\n        new Error('worktree not found'),\n      );\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      // Should not throw — cleanup failure is best-effort\n    });\n\n    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_reopened',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should clear gate and execute phase on directive event', async () => {\n      const task = makeTask({\n        status: 'gated',\n        directive: 'quick',\n        currentPhase: 'review',\n        gatePhase: 'review',\n        gateReason: 'Awaiting approval',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: 'approve' }),\n      );\n\n      expect(task.status).toBe('active');\n      expect(task.gatePhase).toBeNull();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'gate_released',\n        'phase-router',\n        expect.objectContaining({ directive: 'approve' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({ PHASE: 'review' }),\n        task.id,\n      );\n    });\n  });\n\n  describe('executePhase() via route()', () => {\n    it('should create a worktree for the deliver phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/worktree' }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree for the address_review phase', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix-review' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_address_review' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'address_review',\n        expect.objectContaining({\n          WORKTREE_PATH: '/tmp/worktree',\n          PR_NUMBER: '50',\n          PR_BRANCH: 'feat/fix-review',\n        }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree with agents-md prefix for compound phase', async () => {\n      const task = makeTask({ currentPhase: 'compound' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue(\n        '/tmp/compound-worktree',\n      );\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_compound' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'agents-md',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'compound',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/compound-worktree' }),\n        task.id,\n      );\n    });\n\n    it('should pass rcBranch to getOrCreateWorktree when an active release exists', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue({\n        releaseId: 7,\n        key: 'RC-1',\n        title: 'Release 1',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v1.2',\n        baseBranch: 'master',\n      });\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        'rc/v1.2',\n      );\n    });\n\n    it('should pass undefined baseBranch to getOrCreateWorktree when no release is active', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue(null);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n    });\n\n    it('should not create a worktree for non-execute phases', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(worktreeService.getOrCreateWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree creation failure with transient block', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockRejectedValue(\n        new Error('disk full'),\n      );\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(internalAdapter.handleSignal).toHaveBeenCalledWith(\n        task.id,\n        'deliver',\n        'BLOCKED:TRANSIENT',\n        expect.stringContaining('disk full'),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should set phase status to active before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'plan',\n        'active',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('should include PR env vars when task has prNumber and prBranch', async () => {\n      const task = makeTask({\n        prNumber: 99,\n        prBranch: 'feat/test',\n        directive: 'quick',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_review' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({\n          PR_NUMBER: '99',\n          PR_BRANCH: 'feat/test',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include TASK_DIR env var pointing to central path', async () => {\n      const centralPath =\n        '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42';\n      artefacts.buildCentralTaskDir.mockReturnValue(centralPath);\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(artefacts.buildCentralTaskDir).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_DIR: centralPath }),\n        task.id,\n      );\n    });\n\n    it('should call onPhaseStart before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      const callOrder: string[] = [];\n      hooks.onPhaseStart.mockImplementation((): Promise<void> => {\n        callOrder.push('onPhaseStart');\n        return Promise.resolve();\n      });\n      claude.invoke.mockImplementation((): Promise<void> => {\n        callOrder.push('claude');\n        return Promise.resolve();\n      });\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(hooks.onPhaseStart).toHaveBeenCalledWith(\n        expect.objectContaining({ issue: 42, repo: 'org/repo' }),\n        'refine',\n      );\n      expect(callOrder).toEqual(['onPhaseStart', 'claude']);\n    });\n\n    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n\n      await expect(\n        service.route(makeEvent({ type: 'phase_execute' })),\n      ).rejects.toThrow('Claude crash');\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should not cleanup worktree after normal phase execution', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_execute' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('resolvePhase()', () => {\n    const task = makeTask();\n\n    it('should resolve issue_assigned to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve intake to refine', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n        'refine',\n      );\n    });\n\n    it('should resolve issue_comment to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n          task,\n        ),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve phase_* events to the phase name', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n      ).toBe('refine');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n      ).toBe('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_execute' }), task),\n      ).toBe('execute');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n      ).toBe('deliver');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n      ).toBe('compound');\n    });\n\n    it('should resolve _completed events to the next phase', () => {\n      internalAdapter.getNextPhase.mockReturnValue('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'refine_completed' }), task),\n      ).toBe('plan');\n      expect(internalAdapter.getNextPhase).toHaveBeenCalledWith('refine');\n    });\n\n    it('should resolve pr_merged to compound', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'pr_merged' }), task)).toBe(\n        'compound',\n      );\n    });\n\n    it('should resolve review_approved to null (handled specially in route)', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'review_approved' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve review_changes_requested to address_review', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'review_changes_requested' }),\n          task,\n        ),\n      ).toBe('address_review');\n    });\n\n    it('should resolve ci_passed to deliver', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'ci_passed' }), task)).toBe(\n        'deliver',\n      );\n    });\n\n    it('should resolve ci_failure to deliver', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'ci_failure' }), task),\n      ).toBe('deliver');\n    });\n\n    it('should resolve gate_released to the gated phase', () => {\n      const gatedTask = makeTask({ gatePhase: 'plan' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'gate_released' }), gatedTask),\n      ).toBe('plan');\n    });\n\n    it('should resolve pr_comment to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'pr_comment' }), task),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve task_cancelled to null', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_cancelled' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve task_reopened to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_reopened' }), task),\n      ).toBe('refine');\n    });\n\n    it('should return null for unknown events', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'something_unknown' }), task),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already blocked', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'refine',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), blockedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already gated', () => {\n      const gatedTask = makeTask({\n        status: 'gated',\n        currentPhase: 'review',\n        gatePhase: 'review',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), gatedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already past refine', () => {\n      const planTask = makeTask({ currentPhase: 'plan', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), planTask),\n      ).toBeNull();\n    });\n\n    it('should allow intake when task is still at refine', () => {\n      const refineTask = makeTask({ currentPhase: 'refine', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n      ).toBe('refine');\n    });\n\n    it('should resolve directive to gated phase for gated tasks', () => {\n      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n      ).toBe('review');\n    });\n\n    it('should return null for directive on non-gated tasks', () => {\n      const activeTask = makeTask({ status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n      ).toBeNull();\n    });\n\n    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('execute');\n    });\n\n    it('should resolve retry directive from event.directive field', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'retry' }),\n          stuckTask,\n        ),\n      ).toBe('plan');\n    });\n\n    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n          blockedTask,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve restart directive from event.directive field', () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'restart' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'execute' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via event.directive field', () => {\n      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should return null for retry on task with no currentPhase', () => {\n      const taskNoPhase = makeTask({\n        status: 'active',\n        currentPhase: null as unknown as string,\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          taskNoPhase,\n        ),\n      ).toBeNull();\n    });\n  });\n\n  // isGated() removed — gate logic moved to advanceAndEnqueue() in internal-adapter.\n  // Gate tests are now in internal-adapter.service.spec.ts.\n\n  // signal mapping tests removed — result handling moved to InvocationResultListener.\n\n  describe('issue_comment routing', () => {\n    it('should route issue_comment events to the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should route issue_comment with prNumber to reply_to_pr_comment with COMMENT_ID in env', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PHASE: 'reply_to_pr_comment',\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include PR_NUMBER from event when task has no prNumber', async () => {\n      const task = makeTask({ prNumber: null });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should not include AGENT_PERSONA in env for any phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('AGENT_PERSONA');\n    });\n  });\n\n  describe('review phase status on external review events', () => {\n    it('marks review phase complete when review_approved routes to deliver', async () => {\n      // Use quick directive so deliver is not gated and Claude gets invoked\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('marks review phase complete when review_changes_requested routes to address_review', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // address_review phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'review_changes_requested' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n\n    it('skips review phase status update when review is already complete', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('complete') // review status — already complete\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      // setPhaseStatus should NOT be called for review since it's already complete\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n  });\n\n  describe('event recording', () => {\n    it('should record phase_started event', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'phase_started',\n        'phase-router',\n        { phase: 'refine' },\n      );\n    });\n\n    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n    // after the phase completes, not by phase-router before execution.\n  });\n\n  describe('retry/restart directive handling', () => {\n    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'execute',\n        expect.objectContaining({ PHASE: 'execute' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {\n      const task = makeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n    });\n\n    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent retry',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Retry rejected'),\n      );\n    });\n\n    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent restart',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Restart rejected'),\n      );\n    });\n\n    it('unknown directive: posts feedback comment listing valid directives', async () => {\n      const task = makeTask({ status: 'active' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent foobar',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('retry'),\n      );\n    });\n  });\n\n  describe('quality gate override', () => {\n    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n        task.id,\n      );\n    });\n\n    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n    });\n\n    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        false,\n      );\n    });\n  });\n\n  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n\n  describe('idempotency guards', () => {\n    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'blocked',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'Awaiting approval',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        gatePhase: null,\n        gateReason: null,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent approve',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();\n    });\n\n    // artefact sync tests removed — artefact handling moved to InvocationResultListener.\n\n    it('should ignore intake event when task is already complete (late duplicate guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'compound',\n        status: 'complete',\n        phaseCompound: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    it('calls createTask once and invoke once when route() is called twice with same event', async () => {\n      const task = makeTask();\n      // First call: no task exists → create\n      // Second call: task now exists → reuse\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValue(task);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      const event = makeEvent({ type: 'issue_assigned' });\n      await service.route(event);\n      await service.route(event);\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Second call: phase is still 'pending' so invoke runs again (not prevented by active guard),\n      // but the idempotency contract for external events allows this. The important guarantee is\n      // createTask is not called twice.\n      expect(claude.invoke).toHaveBeenCalledTimes(2);\n    });\n\n    it('falls back to existing task when createTask throws unique-constraint error', async () => {\n      const existingTask = makeTask({ id: 5 });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null) // first call: no task\n        .mockResolvedValue(existingTask); // fallback + subsequent calls (paused check etc.)\n      taskState.createTask.mockRejectedValueOnce(\n        Object.assign(new Error('UNIQUE constraint failed'), {\n          code: 'SQLITE_CONSTRAINT',\n        }),\n      );\n      taskState.save.mockResolvedValue(existingTask);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_assigned' }));\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Falls back to the existing task and continues execution\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_ID: String(existingTask.id) }),\n        existingTask.id,\n      );\n    });\n\n    it('re-throws createTask error when no existing task can be found', async () => {\n      const error = Object.assign(new Error('DB error'), {\n        code: 'SOME_OTHER_ERROR',\n      });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValueOnce(null); // fallback also finds nothing\n      taskState.createTask.mockRejectedValueOnce(error);\n\n      await expect(\n        service.route(makeEvent({ type: 'issue_assigned' })),\n      ).rejects.toThrow('DB error');\n    });\n\n    it('skips phase_ event re-execution when phase is failed and task is gated (BLOCKED:PERSISTENT retry)', async () => {\n      const task = makeTask({\n        status: 'gated',\n        gatePhase: 'deliver',\n        gateReason: 'Max retries exceeded',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n\n      // BullMQ stall-retry re-delivers the phase_deliver job\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('allows phase_ re-execution when phase is failed but task is NOT gated (transient retry)', async () => {\n      // InternalAdapterService sets phase to 'failed' and re-enqueues on transient block;\n      // task.status stays 'active'. This legitimate retry must proceed.\n      const task = makeTask({\n        status: 'active',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n      // Phase status is 'active' in-memory on the task passed into executePhase.\n      // This simulates a scenario where route()'s active-phase check was bypassed.\n      const task = makeTask({\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('calls invoke exactly once when route() processes the same event while phase is active', async () => {\n      const task = makeTask({ phaseRefine: 'pending' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // First getPhaseStatus call (route guard): 'pending' → proceed\n      // executePhase() defensive guard also returns 'pending' → proceed to invoke\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      hooks.onPhaseComplete.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n","structuredPatch":[{"oldStart":430,"oldLines":7,"newStart":430,"newLines":7,"lines":["     });"," ","     it('should handle worktree cleanup failure on cancel gracefully', async () => {","-      const task = makeTask({ status: 'active', currentPhase: 'execute' });","+      const task = makeTask({ status: 'active', currentPhase: 'deliver' });","       taskState.findByIssueAndRepo.mockResolvedValue(task);","       taskState.save.mockResolvedValue(task);","       worktreeService.cleanupWorktree.mockRejectedValue("]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01FQxAtZ3Rp5Eo6jR9QFU4GP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TvU8SjsQYhQtCvFCB1hicW","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","old_string":"    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'execute' });","new_string":"    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'deliver' });"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":295,"cache_read_input_tokens":98123,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":295},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"515a3690-9d17-43f7-a22c-9bdbb8a841fe"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TvU8SjsQYhQtCvFCB1hicW","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"4a565454-91af-437c-917d-a4d26bd45d8b","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","oldString":"    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'execute' });","newString":"    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'deliver' });","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { PhaseRouterService } from './phase-router.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\nimport { ClaudeInvocationService } from '../invoke/claude-invocation.service';\nimport { WorktreeService } from '../worktree/worktree.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventService } from '../event/event.service';\nimport { GitHubService } from '../github/github.service';\nimport { TokenUsageService } from '../token-usage/token-usage.service';\nimport { ReleaseService } from '../release/release.service';\nimport { EventBusService } from '../event-bus';\nimport type { TaskEntity } from '../database/entities/task.entity';\nimport type { DispatchEvent } from '../queue/job-queue.interface';\n\nfunction makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n  return {\n    id: 1,\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    currentPhase: 'refine',\n    status: 'active',\n    gatePhase: null,\n    gateReason: null,\n    prNumber: null,\n    prBranch: null,\n    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseReview: 'pending',\n    phaseDeliver: 'pending',\n    phaseCompound: 'pending',\n    qualityGateOverride: false,\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    ...overrides,\n  } as TaskEntity;\n}\n\nfunction makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n  return {\n    type: 'issue_assigned',\n    issueNumber: 42,\n    repo: 'org/repo',\n    ...overrides,\n  };\n}\n\ndescribe('PhaseRouterService', () => {\n  let service: PhaseRouterService;\n  let taskState: jest.Mocked<TaskStateService>;\n  let internalAdapter: jest.Mocked<InternalAdapterService>;\n  let claude: jest.Mocked<ClaudeInvocationService>;\n  let worktreeService: jest.Mocked<WorktreeService>;\n  let artefacts: jest.Mocked<ArtefactsService>;\n  let hooks: jest.Mocked<PhaseHooksService>;\n  let eventService: jest.Mocked<EventService>;\n  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let releaseService: jest.Mocked<\n    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n  >;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        PhaseRouterService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByIssueAndRepo: jest.fn(),\n            createTask: jest.fn(),\n            save: jest.fn(),\n            getPhaseStatus: jest.fn(),\n            setPhaseStatus: jest.fn(),\n            setGate: jest.fn(),\n            clearGate: jest.fn(),\n            resetForRetry: jest.fn(),\n            resetForRestart: jest.fn(),\n            setQualityGateOverride: jest.fn(),\n            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: InternalAdapterService,\n          useValue: {\n            handleSignal: jest.fn(),\n            getNextPhase: jest.fn(),\n          },\n        },\n        {\n          provide: ClaudeInvocationService,\n          useValue: {\n            invoke: jest.fn(),\n          },\n        },\n        {\n          provide: WorktreeService,\n          useValue: {\n            getOrCreateWorktree: jest.fn(),\n            cleanupWorktree: jest.fn(),\n          },\n        },\n        {\n          provide: ArtefactsService,\n          useValue: {\n            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n            buildCentralTaskDir: jest\n              .fn()\n              .mockReturnValue(\n                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n              ),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n            onPhaseComplete: jest.fn(),\n            onPhaseSkip: jest.fn(),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn(),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: GitHubService,\n          useValue: {\n            postComment: jest.fn().mockResolvedValue(undefined),\n            getPRFiles: jest.fn().mockResolvedValue([]),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: TokenUsageService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get(PhaseRouterService);\n    taskState = module.get(TaskStateService);\n    internalAdapter = module.get(InternalAdapterService);\n    claude = module.get(ClaudeInvocationService);\n    worktreeService = module.get(WorktreeService);\n    artefacts = module.get(ArtefactsService);\n    hooks = module.get(PhaseHooksService);\n    eventService = module.get(EventService);\n    gitHub = module.get(GitHubService);\n    eventBus = module.get(EventBusService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  describe('route()', () => {\n    it('should create a new task when none exists and execute the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({\n          ISSUE_NUMBER: '42',\n          REPO: 'org/repo',\n          PHASE: 'refine',\n        }),\n        task.id,\n      );\n    });\n\n    it('should use existing task when found', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should use the directive from the event when creating a task', async () => {\n      const task = makeTask({ directive: 'careful' });\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ directive: 'careful' }));\n\n      expect(taskState.createTask).toHaveBeenCalledWith(\n        42,\n        'org/repo',\n        'careful',\n      );\n    });\n\n    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n      const task = makeTask({ directive: 'careful', currentPhase: 'plan' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      // Plan runs — gate is applied AFTER completion in advanceAndEnqueue, not before\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n      expect(taskState.setGate).not.toHaveBeenCalled();\n    });\n\n    it('should not gate before phase execution (gates are post-completion in internal-adapter)', async () => {\n      const task = makeTask({ directive: 'auto' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should not gate any phase with quick directive', async () => {\n      const task = makeTask({ directive: 'quick' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip when event type cannot be resolved to a phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'unknown_event' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip execution when phase is already active (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should execute phase when phase status is pending (first run)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip completed phase on internal phase_* event (dedup guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip skipped phase on internal phase_* event', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'skipped',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('skipped');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip phase execution when task has been paused by /agent stop', async () => {\n      // Simulate: task.status='paused' was set in DB while a phase_plan\n      // completion event was already in the queue.  The fresh DB read must win.\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'paused',\n        phasePlan: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent stop',\n          actor: 'zhfahan77',\n        }),\n      );\n\n      expect(taskState.save).toHaveBeenCalledWith(\n        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_paused',\n        'phase-router',\n        { actor: 'zhfahan77' },\n      );\n    });\n\n    it('should allow re-executing completed refine on issue_comment event', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_cancelled',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle task_cancelled with no existing task gracefully', async () => {\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(taskState.save).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockRejectedValue(\n        new Error('worktree not found'),\n      );\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      // Should not throw — cleanup failure is best-effort\n    });\n\n    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_reopened',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should clear gate and execute phase on directive event', async () => {\n      const task = makeTask({\n        status: 'gated',\n        directive: 'quick',\n        currentPhase: 'review',\n        gatePhase: 'review',\n        gateReason: 'Awaiting approval',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: 'approve' }),\n      );\n\n      expect(task.status).toBe('active');\n      expect(task.gatePhase).toBeNull();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'gate_released',\n        'phase-router',\n        expect.objectContaining({ directive: 'approve' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({ PHASE: 'review' }),\n        task.id,\n      );\n    });\n  });\n\n  describe('executePhase() via route()', () => {\n    it('should create a worktree for the deliver phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/worktree' }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree for the address_review phase', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix-review' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_address_review' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'address_review',\n        expect.objectContaining({\n          WORKTREE_PATH: '/tmp/worktree',\n          PR_NUMBER: '50',\n          PR_BRANCH: 'feat/fix-review',\n        }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree with agents-md prefix for compound phase', async () => {\n      const task = makeTask({ currentPhase: 'compound' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue(\n        '/tmp/compound-worktree',\n      );\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_compound' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'agents-md',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'compound',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/compound-worktree' }),\n        task.id,\n      );\n    });\n\n    it('should pass rcBranch to getOrCreateWorktree when an active release exists', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue({\n        releaseId: 7,\n        key: 'RC-1',\n        title: 'Release 1',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v1.2',\n        baseBranch: 'master',\n      });\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        'rc/v1.2',\n      );\n    });\n\n    it('should pass undefined baseBranch to getOrCreateWorktree when no release is active', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue(null);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n    });\n\n    it('should not create a worktree for non-execute phases', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(worktreeService.getOrCreateWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree creation failure with transient block', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockRejectedValue(\n        new Error('disk full'),\n      );\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(internalAdapter.handleSignal).toHaveBeenCalledWith(\n        task.id,\n        'deliver',\n        'BLOCKED:TRANSIENT',\n        expect.stringContaining('disk full'),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should set phase status to active before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'plan',\n        'active',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('should include PR env vars when task has prNumber and prBranch', async () => {\n      const task = makeTask({\n        prNumber: 99,\n        prBranch: 'feat/test',\n        directive: 'quick',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_review' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({\n          PR_NUMBER: '99',\n          PR_BRANCH: 'feat/test',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include TASK_DIR env var pointing to central path', async () => {\n      const centralPath =\n        '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42';\n      artefacts.buildCentralTaskDir.mockReturnValue(centralPath);\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(artefacts.buildCentralTaskDir).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_DIR: centralPath }),\n        task.id,\n      );\n    });\n\n    it('should call onPhaseStart before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      const callOrder: string[] = [];\n      hooks.onPhaseStart.mockImplementation((): Promise<void> => {\n        callOrder.push('onPhaseStart');\n        return Promise.resolve();\n      });\n      claude.invoke.mockImplementation((): Promise<void> => {\n        callOrder.push('claude');\n        return Promise.resolve();\n      });\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(hooks.onPhaseStart).toHaveBeenCalledWith(\n        expect.objectContaining({ issue: 42, repo: 'org/repo' }),\n        'refine',\n      );\n      expect(callOrder).toEqual(['onPhaseStart', 'claude']);\n    });\n\n    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n\n      await expect(\n        service.route(makeEvent({ type: 'phase_execute' })),\n      ).rejects.toThrow('Claude crash');\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should not cleanup worktree after normal phase execution', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_execute' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('resolvePhase()', () => {\n    const task = makeTask();\n\n    it('should resolve issue_assigned to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve intake to refine', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n        'refine',\n      );\n    });\n\n    it('should resolve issue_comment to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n          task,\n        ),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve phase_* events to the phase name', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n      ).toBe('refine');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n      ).toBe('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_execute' }), task),\n      ).toBe('execute');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n      ).toBe('deliver');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n      ).toBe('compound');\n    });\n\n    it('should resolve _completed events to the next phase', () => {\n      internalAdapter.getNextPhase.mockReturnValue('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'refine_completed' }), task),\n      ).toBe('plan');\n      expect(internalAdapter.getNextPhase).toHaveBeenCalledWith('refine');\n    });\n\n    it('should resolve pr_merged to compound', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'pr_merged' }), task)).toBe(\n        'compound',\n      );\n    });\n\n    it('should resolve review_approved to null (handled specially in route)', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'review_approved' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve review_changes_requested to address_review', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'review_changes_requested' }),\n          task,\n        ),\n      ).toBe('address_review');\n    });\n\n    it('should resolve ci_passed to deliver', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'ci_passed' }), task)).toBe(\n        'deliver',\n      );\n    });\n\n    it('should resolve ci_failure to deliver', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'ci_failure' }), task),\n      ).toBe('deliver');\n    });\n\n    it('should resolve gate_released to the gated phase', () => {\n      const gatedTask = makeTask({ gatePhase: 'plan' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'gate_released' }), gatedTask),\n      ).toBe('plan');\n    });\n\n    it('should resolve pr_comment to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'pr_comment' }), task),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve task_cancelled to null', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_cancelled' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve task_reopened to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_reopened' }), task),\n      ).toBe('refine');\n    });\n\n    it('should return null for unknown events', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'something_unknown' }), task),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already blocked', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'refine',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), blockedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already gated', () => {\n      const gatedTask = makeTask({\n        status: 'gated',\n        currentPhase: 'review',\n        gatePhase: 'review',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), gatedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already past refine', () => {\n      const planTask = makeTask({ currentPhase: 'plan', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), planTask),\n      ).toBeNull();\n    });\n\n    it('should allow intake when task is still at refine', () => {\n      const refineTask = makeTask({ currentPhase: 'refine', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n      ).toBe('refine');\n    });\n\n    it('should resolve directive to gated phase for gated tasks', () => {\n      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n      ).toBe('review');\n    });\n\n    it('should return null for directive on non-gated tasks', () => {\n      const activeTask = makeTask({ status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n      ).toBeNull();\n    });\n\n    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('execute');\n    });\n\n    it('should resolve retry directive from event.directive field', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'retry' }),\n          stuckTask,\n        ),\n      ).toBe('plan');\n    });\n\n    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n          blockedTask,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve restart directive from event.directive field', () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'restart' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'execute' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via event.directive field', () => {\n      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should return null for retry on task with no currentPhase', () => {\n      const taskNoPhase = makeTask({\n        status: 'active',\n        currentPhase: null as unknown as string,\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          taskNoPhase,\n        ),\n      ).toBeNull();\n    });\n  });\n\n  // isGated() removed — gate logic moved to advanceAndEnqueue() in internal-adapter.\n  // Gate tests are now in internal-adapter.service.spec.ts.\n\n  // signal mapping tests removed — result handling moved to InvocationResultListener.\n\n  describe('issue_comment routing', () => {\n    it('should route issue_comment events to the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should route issue_comment with prNumber to reply_to_pr_comment with COMMENT_ID in env', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PHASE: 'reply_to_pr_comment',\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include PR_NUMBER from event when task has no prNumber', async () => {\n      const task = makeTask({ prNumber: null });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should not include AGENT_PERSONA in env for any phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('AGENT_PERSONA');\n    });\n  });\n\n  describe('review phase status on external review events', () => {\n    it('marks review phase complete when review_approved routes to deliver', async () => {\n      // Use quick directive so deliver is not gated and Claude gets invoked\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('marks review phase complete when review_changes_requested routes to address_review', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // address_review phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'review_changes_requested' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n\n    it('skips review phase status update when review is already complete', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('complete') // review status — already complete\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      // setPhaseStatus should NOT be called for review since it's already complete\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n  });\n\n  describe('event recording', () => {\n    it('should record phase_started event', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'phase_started',\n        'phase-router',\n        { phase: 'refine' },\n      );\n    });\n\n    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n    // after the phase completes, not by phase-router before execution.\n  });\n\n  describe('retry/restart directive handling', () => {\n    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'execute',\n        expect.objectContaining({ PHASE: 'execute' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {\n      const task = makeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n    });\n\n    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent retry',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Retry rejected'),\n      );\n    });\n\n    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent restart',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Restart rejected'),\n      );\n    });\n\n    it('unknown directive: posts feedback comment listing valid directives', async () => {\n      const task = makeTask({ status: 'active' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent foobar',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('retry'),\n      );\n    });\n  });\n\n  describe('quality gate override', () => {\n    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n        task.id,\n      );\n    });\n\n    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n    });\n\n    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        false,\n      );\n    });\n  });\n\n  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n\n  describe('idempotency guards', () => {\n    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'blocked',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'Awaiting approval',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        gatePhase: null,\n        gateReason: null,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent approve',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();\n    });\n\n    // artefact sync tests removed — artefact handling moved to InvocationResultListener.\n\n    it('should ignore intake event when task is already complete (late duplicate guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'compound',\n        status: 'complete',\n        phaseCompound: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    it('calls createTask once and invoke once when route() is called twice with same event', async () => {\n      const task = makeTask();\n      // First call: no task exists → create\n      // Second call: task now exists → reuse\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValue(task);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      const event = makeEvent({ type: 'issue_assigned' });\n      await service.route(event);\n      await service.route(event);\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Second call: phase is still 'pending' so invoke runs again (not prevented by active guard),\n      // but the idempotency contract for external events allows this. The important guarantee is\n      // createTask is not called twice.\n      expect(claude.invoke).toHaveBeenCalledTimes(2);\n    });\n\n    it('falls back to existing task when createTask throws unique-constraint error', async () => {\n      const existingTask = makeTask({ id: 5 });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null) // first call: no task\n        .mockResolvedValue(existingTask); // fallback + subsequent calls (paused check etc.)\n      taskState.createTask.mockRejectedValueOnce(\n        Object.assign(new Error('UNIQUE constraint failed'), {\n          code: 'SQLITE_CONSTRAINT',\n        }),\n      );\n      taskState.save.mockResolvedValue(existingTask);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_assigned' }));\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Falls back to the existing task and continues execution\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_ID: String(existingTask.id) }),\n        existingTask.id,\n      );\n    });\n\n    it('re-throws createTask error when no existing task can be found', async () => {\n      const error = Object.assign(new Error('DB error'), {\n        code: 'SOME_OTHER_ERROR',\n      });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValueOnce(null); // fallback also finds nothing\n      taskState.createTask.mockRejectedValueOnce(error);\n\n      await expect(\n        service.route(makeEvent({ type: 'issue_assigned' })),\n      ).rejects.toThrow('DB error');\n    });\n\n    it('skips phase_ event re-execution when phase is failed and task is gated (BLOCKED:PERSISTENT retry)', async () => {\n      const task = makeTask({\n        status: 'gated',\n        gatePhase: 'deliver',\n        gateReason: 'Max retries exceeded',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n\n      // BullMQ stall-retry re-delivers the phase_deliver job\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('allows phase_ re-execution when phase is failed but task is NOT gated (transient retry)', async () => {\n      // InternalAdapterService sets phase to 'failed' and re-enqueues on transient block;\n      // task.status stays 'active'. This legitimate retry must proceed.\n      const task = makeTask({\n        status: 'active',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n      // Phase status is 'active' in-memory on the task passed into executePhase.\n      // This simulates a scenario where route()'s active-phase check was bypassed.\n      const task = makeTask({\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('calls invoke exactly once when route() processes the same event while phase is active', async () => {\n      const task = makeTask({ phaseRefine: 'pending' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // First getPhaseStatus call (route guard): 'pending' → proceed\n      // executePhase() defensive guard also returns 'pending' → proceed to invoke\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      hooks.onPhaseComplete.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n","structuredPatch":[{"oldStart":447,"oldLines":7,"newStart":447,"newLines":7,"lines":["     });"," ","     it('should reset cancelled task and route to refine on task_reopened', async () => {","-      const task = makeTask({ status: 'cancelled', currentPhase: 'execute' });","+      const task = makeTask({ status: 'cancelled', currentPhase: 'deliver' });","       taskState.findByIssueAndRepo.mockResolvedValue(task);","       taskState.save.mockResolvedValue(task);","       taskState.getPhaseStatus.mockReturnValue('pending');"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_018Mv4grjPmHvUDVHL2LEByP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018t9zm7cnfZcRnAgsHKPUp6","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","old_string":"      await expect(\n        service.route(makeEvent({ type: 'phase_execute' })),\n      ).rejects.toThrow('Claude crash');","new_string":"      await expect(\n        service.route(makeEvent({ type: 'phase_deliver' })),\n      ).rejects.toThrow('Claude crash');"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":301,"cache_read_input_tokens":98418,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":301},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"273be022-8039-4f47-badd-7398933aa296"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018t9zm7cnfZcRnAgsHKPUp6","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"2719fd38-e419-438c-a38e-532bf474f4f7","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","oldString":"      await expect(\n        service.route(makeEvent({ type: 'phase_execute' })),\n      ).rejects.toThrow('Claude crash');","newString":"      await expect(\n        service.route(makeEvent({ type: 'phase_deliver' })),\n      ).rejects.toThrow('Claude crash');","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { PhaseRouterService } from './phase-router.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\nimport { ClaudeInvocationService } from '../invoke/claude-invocation.service';\nimport { WorktreeService } from '../worktree/worktree.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventService } from '../event/event.service';\nimport { GitHubService } from '../github/github.service';\nimport { TokenUsageService } from '../token-usage/token-usage.service';\nimport { ReleaseService } from '../release/release.service';\nimport { EventBusService } from '../event-bus';\nimport type { TaskEntity } from '../database/entities/task.entity';\nimport type { DispatchEvent } from '../queue/job-queue.interface';\n\nfunction makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n  return {\n    id: 1,\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    currentPhase: 'refine',\n    status: 'active',\n    gatePhase: null,\n    gateReason: null,\n    prNumber: null,\n    prBranch: null,\n    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseReview: 'pending',\n    phaseDeliver: 'pending',\n    phaseCompound: 'pending',\n    qualityGateOverride: false,\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    ...overrides,\n  } as TaskEntity;\n}\n\nfunction makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n  return {\n    type: 'issue_assigned',\n    issueNumber: 42,\n    repo: 'org/repo',\n    ...overrides,\n  };\n}\n\ndescribe('PhaseRouterService', () => {\n  let service: PhaseRouterService;\n  let taskState: jest.Mocked<TaskStateService>;\n  let internalAdapter: jest.Mocked<InternalAdapterService>;\n  let claude: jest.Mocked<ClaudeInvocationService>;\n  let worktreeService: jest.Mocked<WorktreeService>;\n  let artefacts: jest.Mocked<ArtefactsService>;\n  let hooks: jest.Mocked<PhaseHooksService>;\n  let eventService: jest.Mocked<EventService>;\n  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let releaseService: jest.Mocked<\n    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n  >;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        PhaseRouterService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByIssueAndRepo: jest.fn(),\n            createTask: jest.fn(),\n            save: jest.fn(),\n            getPhaseStatus: jest.fn(),\n            setPhaseStatus: jest.fn(),\n            setGate: jest.fn(),\n            clearGate: jest.fn(),\n            resetForRetry: jest.fn(),\n            resetForRestart: jest.fn(),\n            setQualityGateOverride: jest.fn(),\n            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: InternalAdapterService,\n          useValue: {\n            handleSignal: jest.fn(),\n            getNextPhase: jest.fn(),\n          },\n        },\n        {\n          provide: ClaudeInvocationService,\n          useValue: {\n            invoke: jest.fn(),\n          },\n        },\n        {\n          provide: WorktreeService,\n          useValue: {\n            getOrCreateWorktree: jest.fn(),\n            cleanupWorktree: jest.fn(),\n          },\n        },\n        {\n          provide: ArtefactsService,\n          useValue: {\n            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n            buildCentralTaskDir: jest\n              .fn()\n              .mockReturnValue(\n                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n              ),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n            onPhaseComplete: jest.fn(),\n            onPhaseSkip: jest.fn(),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn(),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: GitHubService,\n          useValue: {\n            postComment: jest.fn().mockResolvedValue(undefined),\n            getPRFiles: jest.fn().mockResolvedValue([]),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: TokenUsageService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get(PhaseRouterService);\n    taskState = module.get(TaskStateService);\n    internalAdapter = module.get(InternalAdapterService);\n    claude = module.get(ClaudeInvocationService);\n    worktreeService = module.get(WorktreeService);\n    artefacts = module.get(ArtefactsService);\n    hooks = module.get(PhaseHooksService);\n    eventService = module.get(EventService);\n    gitHub = module.get(GitHubService);\n    eventBus = module.get(EventBusService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  describe('route()', () => {\n    it('should create a new task when none exists and execute the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({\n          ISSUE_NUMBER: '42',\n          REPO: 'org/repo',\n          PHASE: 'refine',\n        }),\n        task.id,\n      );\n    });\n\n    it('should use existing task when found', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should use the directive from the event when creating a task', async () => {\n      const task = makeTask({ directive: 'careful' });\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ directive: 'careful' }));\n\n      expect(taskState.createTask).toHaveBeenCalledWith(\n        42,\n        'org/repo',\n        'careful',\n      );\n    });\n\n    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n      const task = makeTask({ directive: 'careful', currentPhase: 'plan' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      // Plan runs — gate is applied AFTER completion in advanceAndEnqueue, not before\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n      expect(taskState.setGate).not.toHaveBeenCalled();\n    });\n\n    it('should not gate before phase execution (gates are post-completion in internal-adapter)', async () => {\n      const task = makeTask({ directive: 'auto' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should not gate any phase with quick directive', async () => {\n      const task = makeTask({ directive: 'quick' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip when event type cannot be resolved to a phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'unknown_event' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip execution when phase is already active (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should execute phase when phase status is pending (first run)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip completed phase on internal phase_* event (dedup guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip skipped phase on internal phase_* event', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'skipped',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('skipped');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip phase execution when task has been paused by /agent stop', async () => {\n      // Simulate: task.status='paused' was set in DB while a phase_plan\n      // completion event was already in the queue.  The fresh DB read must win.\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'paused',\n        phasePlan: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent stop',\n          actor: 'zhfahan77',\n        }),\n      );\n\n      expect(taskState.save).toHaveBeenCalledWith(\n        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_paused',\n        'phase-router',\n        { actor: 'zhfahan77' },\n      );\n    });\n\n    it('should allow re-executing completed refine on issue_comment event', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_cancelled',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle task_cancelled with no existing task gracefully', async () => {\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(taskState.save).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockRejectedValue(\n        new Error('worktree not found'),\n      );\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      // Should not throw — cleanup failure is best-effort\n    });\n\n    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_reopened',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should clear gate and execute phase on directive event', async () => {\n      const task = makeTask({\n        status: 'gated',\n        directive: 'quick',\n        currentPhase: 'review',\n        gatePhase: 'review',\n        gateReason: 'Awaiting approval',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: 'approve' }),\n      );\n\n      expect(task.status).toBe('active');\n      expect(task.gatePhase).toBeNull();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'gate_released',\n        'phase-router',\n        expect.objectContaining({ directive: 'approve' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({ PHASE: 'review' }),\n        task.id,\n      );\n    });\n  });\n\n  describe('executePhase() via route()', () => {\n    it('should create a worktree for the deliver phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/worktree' }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree for the address_review phase', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix-review' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_address_review' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'address_review',\n        expect.objectContaining({\n          WORKTREE_PATH: '/tmp/worktree',\n          PR_NUMBER: '50',\n          PR_BRANCH: 'feat/fix-review',\n        }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree with agents-md prefix for compound phase', async () => {\n      const task = makeTask({ currentPhase: 'compound' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue(\n        '/tmp/compound-worktree',\n      );\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_compound' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'agents-md',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'compound',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/compound-worktree' }),\n        task.id,\n      );\n    });\n\n    it('should pass rcBranch to getOrCreateWorktree when an active release exists', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue({\n        releaseId: 7,\n        key: 'RC-1',\n        title: 'Release 1',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v1.2',\n        baseBranch: 'master',\n      });\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        'rc/v1.2',\n      );\n    });\n\n    it('should pass undefined baseBranch to getOrCreateWorktree when no release is active', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue(null);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n    });\n\n    it('should not create a worktree for non-execute phases', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(worktreeService.getOrCreateWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree creation failure with transient block', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockRejectedValue(\n        new Error('disk full'),\n      );\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(internalAdapter.handleSignal).toHaveBeenCalledWith(\n        task.id,\n        'deliver',\n        'BLOCKED:TRANSIENT',\n        expect.stringContaining('disk full'),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should set phase status to active before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'plan',\n        'active',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('should include PR env vars when task has prNumber and prBranch', async () => {\n      const task = makeTask({\n        prNumber: 99,\n        prBranch: 'feat/test',\n        directive: 'quick',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_review' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({\n          PR_NUMBER: '99',\n          PR_BRANCH: 'feat/test',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include TASK_DIR env var pointing to central path', async () => {\n      const centralPath =\n        '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42';\n      artefacts.buildCentralTaskDir.mockReturnValue(centralPath);\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(artefacts.buildCentralTaskDir).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_DIR: centralPath }),\n        task.id,\n      );\n    });\n\n    it('should call onPhaseStart before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      const callOrder: string[] = [];\n      hooks.onPhaseStart.mockImplementation((): Promise<void> => {\n        callOrder.push('onPhaseStart');\n        return Promise.resolve();\n      });\n      claude.invoke.mockImplementation((): Promise<void> => {\n        callOrder.push('claude');\n        return Promise.resolve();\n      });\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(hooks.onPhaseStart).toHaveBeenCalledWith(\n        expect.objectContaining({ issue: 42, repo: 'org/repo' }),\n        'refine',\n      );\n      expect(callOrder).toEqual(['onPhaseStart', 'claude']);\n    });\n\n    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n\n      await expect(\n        service.route(makeEvent({ type: 'phase_execute' })),\n      ).rejects.toThrow('Claude crash');\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should not cleanup worktree after normal phase execution', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_execute' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('resolvePhase()', () => {\n    const task = makeTask();\n\n    it('should resolve issue_assigned to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve intake to refine', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n        'refine',\n      );\n    });\n\n    it('should resolve issue_comment to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n          task,\n        ),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve phase_* events to the phase name', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n      ).toBe('refine');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n      ).toBe('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_execute' }), task),\n      ).toBe('execute');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n      ).toBe('deliver');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n      ).toBe('compound');\n    });\n\n    it('should resolve _completed events to the next phase', () => {\n      internalAdapter.getNextPhase.mockReturnValue('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'refine_completed' }), task),\n      ).toBe('plan');\n      expect(internalAdapter.getNextPhase).toHaveBeenCalledWith('refine');\n    });\n\n    it('should resolve pr_merged to compound', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'pr_merged' }), task)).toBe(\n        'compound',\n      );\n    });\n\n    it('should resolve review_approved to null (handled specially in route)', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'review_approved' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve review_changes_requested to address_review', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'review_changes_requested' }),\n          task,\n        ),\n      ).toBe('address_review');\n    });\n\n    it('should resolve ci_passed to deliver', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'ci_passed' }), task)).toBe(\n        'deliver',\n      );\n    });\n\n    it('should resolve ci_failure to deliver', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'ci_failure' }), task),\n      ).toBe('deliver');\n    });\n\n    it('should resolve gate_released to the gated phase', () => {\n      const gatedTask = makeTask({ gatePhase: 'plan' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'gate_released' }), gatedTask),\n      ).toBe('plan');\n    });\n\n    it('should resolve pr_comment to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'pr_comment' }), task),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve task_cancelled to null', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_cancelled' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve task_reopened to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_reopened' }), task),\n      ).toBe('refine');\n    });\n\n    it('should return null for unknown events', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'something_unknown' }), task),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already blocked', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'refine',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), blockedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already gated', () => {\n      const gatedTask = makeTask({\n        status: 'gated',\n        currentPhase: 'review',\n        gatePhase: 'review',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), gatedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already past refine', () => {\n      const planTask = makeTask({ currentPhase: 'plan', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), planTask),\n      ).toBeNull();\n    });\n\n    it('should allow intake when task is still at refine', () => {\n      const refineTask = makeTask({ currentPhase: 'refine', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n      ).toBe('refine');\n    });\n\n    it('should resolve directive to gated phase for gated tasks', () => {\n      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n      ).toBe('review');\n    });\n\n    it('should return null for directive on non-gated tasks', () => {\n      const activeTask = makeTask({ status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n      ).toBeNull();\n    });\n\n    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('execute');\n    });\n\n    it('should resolve retry directive from event.directive field', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'retry' }),\n          stuckTask,\n        ),\n      ).toBe('plan');\n    });\n\n    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n          blockedTask,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve restart directive from event.directive field', () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'restart' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'execute' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via event.directive field', () => {\n      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should return null for retry on task with no currentPhase', () => {\n      const taskNoPhase = makeTask({\n        status: 'active',\n        currentPhase: null as unknown as string,\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          taskNoPhase,\n        ),\n      ).toBeNull();\n    });\n  });\n\n  // isGated() removed — gate logic moved to advanceAndEnqueue() in internal-adapter.\n  // Gate tests are now in internal-adapter.service.spec.ts.\n\n  // signal mapping tests removed — result handling moved to InvocationResultListener.\n\n  describe('issue_comment routing', () => {\n    it('should route issue_comment events to the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should route issue_comment with prNumber to reply_to_pr_comment with COMMENT_ID in env', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PHASE: 'reply_to_pr_comment',\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include PR_NUMBER from event when task has no prNumber', async () => {\n      const task = makeTask({ prNumber: null });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should not include AGENT_PERSONA in env for any phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('AGENT_PERSONA');\n    });\n  });\n\n  describe('review phase status on external review events', () => {\n    it('marks review phase complete when review_approved routes to deliver', async () => {\n      // Use quick directive so deliver is not gated and Claude gets invoked\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('marks review phase complete when review_changes_requested routes to address_review', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // address_review phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'review_changes_requested' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n\n    it('skips review phase status update when review is already complete', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('complete') // review status — already complete\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      // setPhaseStatus should NOT be called for review since it's already complete\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n  });\n\n  describe('event recording', () => {\n    it('should record phase_started event', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'phase_started',\n        'phase-router',\n        { phase: 'refine' },\n      );\n    });\n\n    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n    // after the phase completes, not by phase-router before execution.\n  });\n\n  describe('retry/restart directive handling', () => {\n    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'execute',\n        expect.objectContaining({ PHASE: 'execute' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {\n      const task = makeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n    });\n\n    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent retry',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Retry rejected'),\n      );\n    });\n\n    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent restart',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Restart rejected'),\n      );\n    });\n\n    it('unknown directive: posts feedback comment listing valid directives', async () => {\n      const task = makeTask({ status: 'active' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent foobar',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('retry'),\n      );\n    });\n  });\n\n  describe('quality gate override', () => {\n    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n        task.id,\n      );\n    });\n\n    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n    });\n\n    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        false,\n      );\n    });\n  });\n\n  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n\n  describe('idempotency guards', () => {\n    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'blocked',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'Awaiting approval',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        gatePhase: null,\n        gateReason: null,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent approve',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();\n    });\n\n    // artefact sync tests removed — artefact handling moved to InvocationResultListener.\n\n    it('should ignore intake event when task is already complete (late duplicate guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'compound',\n        status: 'complete',\n        phaseCompound: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    it('calls createTask once and invoke once when route() is called twice with same event', async () => {\n      const task = makeTask();\n      // First call: no task exists → create\n      // Second call: task now exists → reuse\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValue(task);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      const event = makeEvent({ type: 'issue_assigned' });\n      await service.route(event);\n      await service.route(event);\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Second call: phase is still 'pending' so invoke runs again (not prevented by active guard),\n      // but the idempotency contract for external events allows this. The important guarantee is\n      // createTask is not called twice.\n      expect(claude.invoke).toHaveBeenCalledTimes(2);\n    });\n\n    it('falls back to existing task when createTask throws unique-constraint error', async () => {\n      const existingTask = makeTask({ id: 5 });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null) // first call: no task\n        .mockResolvedValue(existingTask); // fallback + subsequent calls (paused check etc.)\n      taskState.createTask.mockRejectedValueOnce(\n        Object.assign(new Error('UNIQUE constraint failed'), {\n          code: 'SQLITE_CONSTRAINT',\n        }),\n      );\n      taskState.save.mockResolvedValue(existingTask);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_assigned' }));\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Falls back to the existing task and continues execution\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_ID: String(existingTask.id) }),\n        existingTask.id,\n      );\n    });\n\n    it('re-throws createTask error when no existing task can be found', async () => {\n      const error = Object.assign(new Error('DB error'), {\n        code: 'SOME_OTHER_ERROR',\n      });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValueOnce(null); // fallback also finds nothing\n      taskState.createTask.mockRejectedValueOnce(error);\n\n      await expect(\n        service.route(makeEvent({ type: 'issue_assigned' })),\n      ).rejects.toThrow('DB error');\n    });\n\n    it('skips phase_ event re-execution when phase is failed and task is gated (BLOCKED:PERSISTENT retry)', async () => {\n      const task = makeTask({\n        status: 'gated',\n        gatePhase: 'deliver',\n        gateReason: 'Max retries exceeded',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n\n      // BullMQ stall-retry re-delivers the phase_deliver job\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('allows phase_ re-execution when phase is failed but task is NOT gated (transient retry)', async () => {\n      // InternalAdapterService sets phase to 'failed' and re-enqueues on transient block;\n      // task.status stays 'active'. This legitimate retry must proceed.\n      const task = makeTask({\n        status: 'active',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n      // Phase status is 'active' in-memory on the task passed into executePhase.\n      // This simulates a scenario where route()'s active-phase check was bypassed.\n      const task = makeTask({\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('calls invoke exactly once when route() processes the same event while phase is active', async () => {\n      const task = makeTask({ phaseRefine: 'pending' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // First getPhaseStatus call (route guard): 'pending' → proceed\n      // executePhase() defensive guard also returns 'pending' → proceed to invoke\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      hooks.onPhaseComplete.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n","structuredPatch":[{"oldStart":746,"oldLines":7,"newStart":746,"newLines":7,"lines":["       claude.invoke.mockRejectedValue(new Error('Claude crash'));"," ","       await expect(","-        service.route(makeEvent({ type: 'phase_execute' })),","+        service.route(makeEvent({ type: 'phase_deliver' })),","       ).rejects.toThrow('Claude crash');"," ","       expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01MvjKnJNQ1RgP2ukpqxbghk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WfzZfaXdv7sdkpGqBRp4ee","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","old_string":"      await service.route(makeEvent({ type: 'phase_execute' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });","new_string":"      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":283,"cache_read_input_tokens":98719,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":283},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"35f908f4-7acd-4d07-94f0-1449ad961aac"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WfzZfaXdv7sdkpGqBRp4ee","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"ec41497e-65ba-4d13-b906-8e5c3bc222e3","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","oldString":"      await service.route(makeEvent({ type: 'phase_execute' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });","newString":"      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { PhaseRouterService } from './phase-router.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\nimport { ClaudeInvocationService } from '../invoke/claude-invocation.service';\nimport { WorktreeService } from '../worktree/worktree.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventService } from '../event/event.service';\nimport { GitHubService } from '../github/github.service';\nimport { TokenUsageService } from '../token-usage/token-usage.service';\nimport { ReleaseService } from '../release/release.service';\nimport { EventBusService } from '../event-bus';\nimport type { TaskEntity } from '../database/entities/task.entity';\nimport type { DispatchEvent } from '../queue/job-queue.interface';\n\nfunction makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n  return {\n    id: 1,\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    currentPhase: 'refine',\n    status: 'active',\n    gatePhase: null,\n    gateReason: null,\n    prNumber: null,\n    prBranch: null,\n    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseReview: 'pending',\n    phaseDeliver: 'pending',\n    phaseCompound: 'pending',\n    qualityGateOverride: false,\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    ...overrides,\n  } as TaskEntity;\n}\n\nfunction makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n  return {\n    type: 'issue_assigned',\n    issueNumber: 42,\n    repo: 'org/repo',\n    ...overrides,\n  };\n}\n\ndescribe('PhaseRouterService', () => {\n  let service: PhaseRouterService;\n  let taskState: jest.Mocked<TaskStateService>;\n  let internalAdapter: jest.Mocked<InternalAdapterService>;\n  let claude: jest.Mocked<ClaudeInvocationService>;\n  let worktreeService: jest.Mocked<WorktreeService>;\n  let artefacts: jest.Mocked<ArtefactsService>;\n  let hooks: jest.Mocked<PhaseHooksService>;\n  let eventService: jest.Mocked<EventService>;\n  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let releaseService: jest.Mocked<\n    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n  >;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        PhaseRouterService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByIssueAndRepo: jest.fn(),\n            createTask: jest.fn(),\n            save: jest.fn(),\n            getPhaseStatus: jest.fn(),\n            setPhaseStatus: jest.fn(),\n            setGate: jest.fn(),\n            clearGate: jest.fn(),\n            resetForRetry: jest.fn(),\n            resetForRestart: jest.fn(),\n            setQualityGateOverride: jest.fn(),\n            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: InternalAdapterService,\n          useValue: {\n            handleSignal: jest.fn(),\n            getNextPhase: jest.fn(),\n          },\n        },\n        {\n          provide: ClaudeInvocationService,\n          useValue: {\n            invoke: jest.fn(),\n          },\n        },\n        {\n          provide: WorktreeService,\n          useValue: {\n            getOrCreateWorktree: jest.fn(),\n            cleanupWorktree: jest.fn(),\n          },\n        },\n        {\n          provide: ArtefactsService,\n          useValue: {\n            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n            buildCentralTaskDir: jest\n              .fn()\n              .mockReturnValue(\n                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n              ),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n            onPhaseComplete: jest.fn(),\n            onPhaseSkip: jest.fn(),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn(),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: GitHubService,\n          useValue: {\n            postComment: jest.fn().mockResolvedValue(undefined),\n            getPRFiles: jest.fn().mockResolvedValue([]),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: TokenUsageService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get(PhaseRouterService);\n    taskState = module.get(TaskStateService);\n    internalAdapter = module.get(InternalAdapterService);\n    claude = module.get(ClaudeInvocationService);\n    worktreeService = module.get(WorktreeService);\n    artefacts = module.get(ArtefactsService);\n    hooks = module.get(PhaseHooksService);\n    eventService = module.get(EventService);\n    gitHub = module.get(GitHubService);\n    eventBus = module.get(EventBusService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  describe('route()', () => {\n    it('should create a new task when none exists and execute the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({\n          ISSUE_NUMBER: '42',\n          REPO: 'org/repo',\n          PHASE: 'refine',\n        }),\n        task.id,\n      );\n    });\n\n    it('should use existing task when found', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should use the directive from the event when creating a task', async () => {\n      const task = makeTask({ directive: 'careful' });\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ directive: 'careful' }));\n\n      expect(taskState.createTask).toHaveBeenCalledWith(\n        42,\n        'org/repo',\n        'careful',\n      );\n    });\n\n    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n      const task = makeTask({ directive: 'careful', currentPhase: 'plan' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      // Plan runs — gate is applied AFTER completion in advanceAndEnqueue, not before\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n      expect(taskState.setGate).not.toHaveBeenCalled();\n    });\n\n    it('should not gate before phase execution (gates are post-completion in internal-adapter)', async () => {\n      const task = makeTask({ directive: 'auto' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should not gate any phase with quick directive', async () => {\n      const task = makeTask({ directive: 'quick' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip when event type cannot be resolved to a phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'unknown_event' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip execution when phase is already active (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should execute phase when phase status is pending (first run)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip completed phase on internal phase_* event (dedup guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip skipped phase on internal phase_* event', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'skipped',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('skipped');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip phase execution when task has been paused by /agent stop', async () => {\n      // Simulate: task.status='paused' was set in DB while a phase_plan\n      // completion event was already in the queue.  The fresh DB read must win.\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'paused',\n        phasePlan: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent stop',\n          actor: 'zhfahan77',\n        }),\n      );\n\n      expect(taskState.save).toHaveBeenCalledWith(\n        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_paused',\n        'phase-router',\n        { actor: 'zhfahan77' },\n      );\n    });\n\n    it('should allow re-executing completed refine on issue_comment event', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_cancelled',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle task_cancelled with no existing task gracefully', async () => {\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(taskState.save).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockRejectedValue(\n        new Error('worktree not found'),\n      );\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      // Should not throw — cleanup failure is best-effort\n    });\n\n    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_reopened',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should clear gate and execute phase on directive event', async () => {\n      const task = makeTask({\n        status: 'gated',\n        directive: 'quick',\n        currentPhase: 'review',\n        gatePhase: 'review',\n        gateReason: 'Awaiting approval',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: 'approve' }),\n      );\n\n      expect(task.status).toBe('active');\n      expect(task.gatePhase).toBeNull();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'gate_released',\n        'phase-router',\n        expect.objectContaining({ directive: 'approve' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({ PHASE: 'review' }),\n        task.id,\n      );\n    });\n  });\n\n  describe('executePhase() via route()', () => {\n    it('should create a worktree for the deliver phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/worktree' }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree for the address_review phase', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix-review' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_address_review' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'address_review',\n        expect.objectContaining({\n          WORKTREE_PATH: '/tmp/worktree',\n          PR_NUMBER: '50',\n          PR_BRANCH: 'feat/fix-review',\n        }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree with agents-md prefix for compound phase', async () => {\n      const task = makeTask({ currentPhase: 'compound' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue(\n        '/tmp/compound-worktree',\n      );\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_compound' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'agents-md',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'compound',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/compound-worktree' }),\n        task.id,\n      );\n    });\n\n    it('should pass rcBranch to getOrCreateWorktree when an active release exists', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue({\n        releaseId: 7,\n        key: 'RC-1',\n        title: 'Release 1',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v1.2',\n        baseBranch: 'master',\n      });\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        'rc/v1.2',\n      );\n    });\n\n    it('should pass undefined baseBranch to getOrCreateWorktree when no release is active', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue(null);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n    });\n\n    it('should not create a worktree for non-execute phases', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(worktreeService.getOrCreateWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree creation failure with transient block', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockRejectedValue(\n        new Error('disk full'),\n      );\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(internalAdapter.handleSignal).toHaveBeenCalledWith(\n        task.id,\n        'deliver',\n        'BLOCKED:TRANSIENT',\n        expect.stringContaining('disk full'),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should set phase status to active before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'plan',\n        'active',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('should include PR env vars when task has prNumber and prBranch', async () => {\n      const task = makeTask({\n        prNumber: 99,\n        prBranch: 'feat/test',\n        directive: 'quick',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_review' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({\n          PR_NUMBER: '99',\n          PR_BRANCH: 'feat/test',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include TASK_DIR env var pointing to central path', async () => {\n      const centralPath =\n        '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42';\n      artefacts.buildCentralTaskDir.mockReturnValue(centralPath);\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(artefacts.buildCentralTaskDir).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_DIR: centralPath }),\n        task.id,\n      );\n    });\n\n    it('should call onPhaseStart before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      const callOrder: string[] = [];\n      hooks.onPhaseStart.mockImplementation((): Promise<void> => {\n        callOrder.push('onPhaseStart');\n        return Promise.resolve();\n      });\n      claude.invoke.mockImplementation((): Promise<void> => {\n        callOrder.push('claude');\n        return Promise.resolve();\n      });\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(hooks.onPhaseStart).toHaveBeenCalledWith(\n        expect.objectContaining({ issue: 42, repo: 'org/repo' }),\n        'refine',\n      );\n      expect(callOrder).toEqual(['onPhaseStart', 'claude']);\n    });\n\n    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n\n      await expect(\n        service.route(makeEvent({ type: 'phase_deliver' })),\n      ).rejects.toThrow('Claude crash');\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should not cleanup worktree after normal phase execution', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_execute' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('resolvePhase()', () => {\n    const task = makeTask();\n\n    it('should resolve issue_assigned to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve intake to refine', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n        'refine',\n      );\n    });\n\n    it('should resolve issue_comment to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n          task,\n        ),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve phase_* events to the phase name', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n      ).toBe('refine');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n      ).toBe('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_execute' }), task),\n      ).toBe('execute');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n      ).toBe('deliver');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n      ).toBe('compound');\n    });\n\n    it('should resolve _completed events to the next phase', () => {\n      internalAdapter.getNextPhase.mockReturnValue('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'refine_completed' }), task),\n      ).toBe('plan');\n      expect(internalAdapter.getNextPhase).toHaveBeenCalledWith('refine');\n    });\n\n    it('should resolve pr_merged to compound', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'pr_merged' }), task)).toBe(\n        'compound',\n      );\n    });\n\n    it('should resolve review_approved to null (handled specially in route)', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'review_approved' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve review_changes_requested to address_review', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'review_changes_requested' }),\n          task,\n        ),\n      ).toBe('address_review');\n    });\n\n    it('should resolve ci_passed to deliver', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'ci_passed' }), task)).toBe(\n        'deliver',\n      );\n    });\n\n    it('should resolve ci_failure to deliver', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'ci_failure' }), task),\n      ).toBe('deliver');\n    });\n\n    it('should resolve gate_released to the gated phase', () => {\n      const gatedTask = makeTask({ gatePhase: 'plan' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'gate_released' }), gatedTask),\n      ).toBe('plan');\n    });\n\n    it('should resolve pr_comment to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'pr_comment' }), task),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve task_cancelled to null', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_cancelled' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve task_reopened to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_reopened' }), task),\n      ).toBe('refine');\n    });\n\n    it('should return null for unknown events', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'something_unknown' }), task),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already blocked', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'refine',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), blockedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already gated', () => {\n      const gatedTask = makeTask({\n        status: 'gated',\n        currentPhase: 'review',\n        gatePhase: 'review',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), gatedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already past refine', () => {\n      const planTask = makeTask({ currentPhase: 'plan', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), planTask),\n      ).toBeNull();\n    });\n\n    it('should allow intake when task is still at refine', () => {\n      const refineTask = makeTask({ currentPhase: 'refine', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n      ).toBe('refine');\n    });\n\n    it('should resolve directive to gated phase for gated tasks', () => {\n      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n      ).toBe('review');\n    });\n\n    it('should return null for directive on non-gated tasks', () => {\n      const activeTask = makeTask({ status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n      ).toBeNull();\n    });\n\n    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('execute');\n    });\n\n    it('should resolve retry directive from event.directive field', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'retry' }),\n          stuckTask,\n        ),\n      ).toBe('plan');\n    });\n\n    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n          blockedTask,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve restart directive from event.directive field', () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'restart' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'execute' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via event.directive field', () => {\n      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should return null for retry on task with no currentPhase', () => {\n      const taskNoPhase = makeTask({\n        status: 'active',\n        currentPhase: null as unknown as string,\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          taskNoPhase,\n        ),\n      ).toBeNull();\n    });\n  });\n\n  // isGated() removed — gate logic moved to advanceAndEnqueue() in internal-adapter.\n  // Gate tests are now in internal-adapter.service.spec.ts.\n\n  // signal mapping tests removed — result handling moved to InvocationResultListener.\n\n  describe('issue_comment routing', () => {\n    it('should route issue_comment events to the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should route issue_comment with prNumber to reply_to_pr_comment with COMMENT_ID in env', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PHASE: 'reply_to_pr_comment',\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include PR_NUMBER from event when task has no prNumber', async () => {\n      const task = makeTask({ prNumber: null });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should not include AGENT_PERSONA in env for any phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('AGENT_PERSONA');\n    });\n  });\n\n  describe('review phase status on external review events', () => {\n    it('marks review phase complete when review_approved routes to deliver', async () => {\n      // Use quick directive so deliver is not gated and Claude gets invoked\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('marks review phase complete when review_changes_requested routes to address_review', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // address_review phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'review_changes_requested' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n\n    it('skips review phase status update when review is already complete', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('complete') // review status — already complete\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      // setPhaseStatus should NOT be called for review since it's already complete\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n  });\n\n  describe('event recording', () => {\n    it('should record phase_started event', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'phase_started',\n        'phase-router',\n        { phase: 'refine' },\n      );\n    });\n\n    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n    // after the phase completes, not by phase-router before execution.\n  });\n\n  describe('retry/restart directive handling', () => {\n    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'execute',\n        expect.objectContaining({ PHASE: 'execute' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {\n      const task = makeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n    });\n\n    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent retry',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Retry rejected'),\n      );\n    });\n\n    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent restart',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Restart rejected'),\n      );\n    });\n\n    it('unknown directive: posts feedback comment listing valid directives', async () => {\n      const task = makeTask({ status: 'active' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent foobar',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('retry'),\n      );\n    });\n  });\n\n  describe('quality gate override', () => {\n    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n        task.id,\n      );\n    });\n\n    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n    });\n\n    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        false,\n      );\n    });\n  });\n\n  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n\n  describe('idempotency guards', () => {\n    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'blocked',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'Awaiting approval',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        gatePhase: null,\n        gateReason: null,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent approve',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();\n    });\n\n    // artefact sync tests removed — artefact handling moved to InvocationResultListener.\n\n    it('should ignore intake event when task is already complete (late duplicate guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'compound',\n        status: 'complete',\n        phaseCompound: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    it('calls createTask once and invoke once when route() is called twice with same event', async () => {\n      const task = makeTask();\n      // First call: no task exists → create\n      // Second call: task now exists → reuse\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValue(task);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      const event = makeEvent({ type: 'issue_assigned' });\n      await service.route(event);\n      await service.route(event);\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Second call: phase is still 'pending' so invoke runs again (not prevented by active guard),\n      // but the idempotency contract for external events allows this. The important guarantee is\n      // createTask is not called twice.\n      expect(claude.invoke).toHaveBeenCalledTimes(2);\n    });\n\n    it('falls back to existing task when createTask throws unique-constraint error', async () => {\n      const existingTask = makeTask({ id: 5 });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null) // first call: no task\n        .mockResolvedValue(existingTask); // fallback + subsequent calls (paused check etc.)\n      taskState.createTask.mockRejectedValueOnce(\n        Object.assign(new Error('UNIQUE constraint failed'), {\n          code: 'SQLITE_CONSTRAINT',\n        }),\n      );\n      taskState.save.mockResolvedValue(existingTask);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_assigned' }));\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Falls back to the existing task and continues execution\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_ID: String(existingTask.id) }),\n        existingTask.id,\n      );\n    });\n\n    it('re-throws createTask error when no existing task can be found', async () => {\n      const error = Object.assign(new Error('DB error'), {\n        code: 'SOME_OTHER_ERROR',\n      });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValueOnce(null); // fallback also finds nothing\n      taskState.createTask.mockRejectedValueOnce(error);\n\n      await expect(\n        service.route(makeEvent({ type: 'issue_assigned' })),\n      ).rejects.toThrow('DB error');\n    });\n\n    it('skips phase_ event re-execution when phase is failed and task is gated (BLOCKED:PERSISTENT retry)', async () => {\n      const task = makeTask({\n        status: 'gated',\n        gatePhase: 'deliver',\n        gateReason: 'Max retries exceeded',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n\n      // BullMQ stall-retry re-delivers the phase_deliver job\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('allows phase_ re-execution when phase is failed but task is NOT gated (transient retry)', async () => {\n      // InternalAdapterService sets phase to 'failed' and re-enqueues on transient block;\n      // task.status stays 'active'. This legitimate retry must proceed.\n      const task = makeTask({\n        status: 'active',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n      // Phase status is 'active' in-memory on the task passed into executePhase.\n      // This simulates a scenario where route()'s active-phase check was bypassed.\n      const task = makeTask({\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('calls invoke exactly once when route() processes the same event while phase is active', async () => {\n      const task = makeTask({ phaseRefine: 'pending' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // First getPhaseStatus call (route guard): 'pending' → proceed\n      // executePhase() defensive guard also returns 'pending' → proceed to invoke\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      hooks.onPhaseComplete.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n","structuredPatch":[{"oldStart":759,"oldLines":7,"newStart":759,"newLines":7,"lines":["       worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');","       claude.invoke.mockResolvedValue(undefined);"," ","-      await service.route(makeEvent({ type: 'phase_execute' }));","+      await service.route(makeEvent({ type: 'phase_deliver' }));"," ","       expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();","     });"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01SQUBAP7J8jJWwbEQo2oSmS","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01YBaJEuwNTM6Zq5SjJkcNhi","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","old_string":"      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_execute' }), task),\n      ).toBe('execute');\n      expect(","new_string":"      expect("},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":311,"cache_read_input_tokens":99002,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":311},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"ba3f686f-4485-45bc-941b-7bb219ee2e22"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01YBaJEuwNTM6Zq5SjJkcNhi","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"7e1fc05c-ec1b-46be-b2eb-3348d5456312","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","oldString":"      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_execute' }), task),\n      ).toBe('execute');\n      expect(","newString":"      expect(","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { PhaseRouterService } from './phase-router.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\nimport { ClaudeInvocationService } from '../invoke/claude-invocation.service';\nimport { WorktreeService } from '../worktree/worktree.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventService } from '../event/event.service';\nimport { GitHubService } from '../github/github.service';\nimport { TokenUsageService } from '../token-usage/token-usage.service';\nimport { ReleaseService } from '../release/release.service';\nimport { EventBusService } from '../event-bus';\nimport type { TaskEntity } from '../database/entities/task.entity';\nimport type { DispatchEvent } from '../queue/job-queue.interface';\n\nfunction makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n  return {\n    id: 1,\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    currentPhase: 'refine',\n    status: 'active',\n    gatePhase: null,\n    gateReason: null,\n    prNumber: null,\n    prBranch: null,\n    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseReview: 'pending',\n    phaseDeliver: 'pending',\n    phaseCompound: 'pending',\n    qualityGateOverride: false,\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    ...overrides,\n  } as TaskEntity;\n}\n\nfunction makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n  return {\n    type: 'issue_assigned',\n    issueNumber: 42,\n    repo: 'org/repo',\n    ...overrides,\n  };\n}\n\ndescribe('PhaseRouterService', () => {\n  let service: PhaseRouterService;\n  let taskState: jest.Mocked<TaskStateService>;\n  let internalAdapter: jest.Mocked<InternalAdapterService>;\n  let claude: jest.Mocked<ClaudeInvocationService>;\n  let worktreeService: jest.Mocked<WorktreeService>;\n  let artefacts: jest.Mocked<ArtefactsService>;\n  let hooks: jest.Mocked<PhaseHooksService>;\n  let eventService: jest.Mocked<EventService>;\n  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let releaseService: jest.Mocked<\n    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n  >;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        PhaseRouterService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByIssueAndRepo: jest.fn(),\n            createTask: jest.fn(),\n            save: jest.fn(),\n            getPhaseStatus: jest.fn(),\n            setPhaseStatus: jest.fn(),\n            setGate: jest.fn(),\n            clearGate: jest.fn(),\n            resetForRetry: jest.fn(),\n            resetForRestart: jest.fn(),\n            setQualityGateOverride: jest.fn(),\n            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: InternalAdapterService,\n          useValue: {\n            handleSignal: jest.fn(),\n            getNextPhase: jest.fn(),\n          },\n        },\n        {\n          provide: ClaudeInvocationService,\n          useValue: {\n            invoke: jest.fn(),\n          },\n        },\n        {\n          provide: WorktreeService,\n          useValue: {\n            getOrCreateWorktree: jest.fn(),\n            cleanupWorktree: jest.fn(),\n          },\n        },\n        {\n          provide: ArtefactsService,\n          useValue: {\n            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n            buildCentralTaskDir: jest\n              .fn()\n              .mockReturnValue(\n                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n              ),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n            onPhaseComplete: jest.fn(),\n            onPhaseSkip: jest.fn(),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn(),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: GitHubService,\n          useValue: {\n            postComment: jest.fn().mockResolvedValue(undefined),\n            getPRFiles: jest.fn().mockResolvedValue([]),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: TokenUsageService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get(PhaseRouterService);\n    taskState = module.get(TaskStateService);\n    internalAdapter = module.get(InternalAdapterService);\n    claude = module.get(ClaudeInvocationService);\n    worktreeService = module.get(WorktreeService);\n    artefacts = module.get(ArtefactsService);\n    hooks = module.get(PhaseHooksService);\n    eventService = module.get(EventService);\n    gitHub = module.get(GitHubService);\n    eventBus = module.get(EventBusService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  describe('route()', () => {\n    it('should create a new task when none exists and execute the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({\n          ISSUE_NUMBER: '42',\n          REPO: 'org/repo',\n          PHASE: 'refine',\n        }),\n        task.id,\n      );\n    });\n\n    it('should use existing task when found', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should use the directive from the event when creating a task', async () => {\n      const task = makeTask({ directive: 'careful' });\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ directive: 'careful' }));\n\n      expect(taskState.createTask).toHaveBeenCalledWith(\n        42,\n        'org/repo',\n        'careful',\n      );\n    });\n\n    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n      const task = makeTask({ directive: 'careful', currentPhase: 'plan' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      // Plan runs — gate is applied AFTER completion in advanceAndEnqueue, not before\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n      expect(taskState.setGate).not.toHaveBeenCalled();\n    });\n\n    it('should not gate before phase execution (gates are post-completion in internal-adapter)', async () => {\n      const task = makeTask({ directive: 'auto' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should not gate any phase with quick directive', async () => {\n      const task = makeTask({ directive: 'quick' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip when event type cannot be resolved to a phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'unknown_event' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip execution when phase is already active (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should execute phase when phase status is pending (first run)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip completed phase on internal phase_* event (dedup guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip skipped phase on internal phase_* event', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'skipped',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('skipped');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip phase execution when task has been paused by /agent stop', async () => {\n      // Simulate: task.status='paused' was set in DB while a phase_plan\n      // completion event was already in the queue.  The fresh DB read must win.\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'paused',\n        phasePlan: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent stop',\n          actor: 'zhfahan77',\n        }),\n      );\n\n      expect(taskState.save).toHaveBeenCalledWith(\n        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_paused',\n        'phase-router',\n        { actor: 'zhfahan77' },\n      );\n    });\n\n    it('should allow re-executing completed refine on issue_comment event', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_cancelled',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle task_cancelled with no existing task gracefully', async () => {\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(taskState.save).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockRejectedValue(\n        new Error('worktree not found'),\n      );\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      // Should not throw — cleanup failure is best-effort\n    });\n\n    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_reopened',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should clear gate and execute phase on directive event', async () => {\n      const task = makeTask({\n        status: 'gated',\n        directive: 'quick',\n        currentPhase: 'review',\n        gatePhase: 'review',\n        gateReason: 'Awaiting approval',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: 'approve' }),\n      );\n\n      expect(task.status).toBe('active');\n      expect(task.gatePhase).toBeNull();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'gate_released',\n        'phase-router',\n        expect.objectContaining({ directive: 'approve' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({ PHASE: 'review' }),\n        task.id,\n      );\n    });\n  });\n\n  describe('executePhase() via route()', () => {\n    it('should create a worktree for the deliver phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/worktree' }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree for the address_review phase', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix-review' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_address_review' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'address_review',\n        expect.objectContaining({\n          WORKTREE_PATH: '/tmp/worktree',\n          PR_NUMBER: '50',\n          PR_BRANCH: 'feat/fix-review',\n        }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree with agents-md prefix for compound phase', async () => {\n      const task = makeTask({ currentPhase: 'compound' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue(\n        '/tmp/compound-worktree',\n      );\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_compound' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'agents-md',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'compound',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/compound-worktree' }),\n        task.id,\n      );\n    });\n\n    it('should pass rcBranch to getOrCreateWorktree when an active release exists', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue({\n        releaseId: 7,\n        key: 'RC-1',\n        title: 'Release 1',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v1.2',\n        baseBranch: 'master',\n      });\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        'rc/v1.2',\n      );\n    });\n\n    it('should pass undefined baseBranch to getOrCreateWorktree when no release is active', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue(null);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n    });\n\n    it('should not create a worktree for non-execute phases', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(worktreeService.getOrCreateWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree creation failure with transient block', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockRejectedValue(\n        new Error('disk full'),\n      );\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(internalAdapter.handleSignal).toHaveBeenCalledWith(\n        task.id,\n        'deliver',\n        'BLOCKED:TRANSIENT',\n        expect.stringContaining('disk full'),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should set phase status to active before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'plan',\n        'active',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('should include PR env vars when task has prNumber and prBranch', async () => {\n      const task = makeTask({\n        prNumber: 99,\n        prBranch: 'feat/test',\n        directive: 'quick',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_review' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({\n          PR_NUMBER: '99',\n          PR_BRANCH: 'feat/test',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include TASK_DIR env var pointing to central path', async () => {\n      const centralPath =\n        '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42';\n      artefacts.buildCentralTaskDir.mockReturnValue(centralPath);\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(artefacts.buildCentralTaskDir).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_DIR: centralPath }),\n        task.id,\n      );\n    });\n\n    it('should call onPhaseStart before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      const callOrder: string[] = [];\n      hooks.onPhaseStart.mockImplementation((): Promise<void> => {\n        callOrder.push('onPhaseStart');\n        return Promise.resolve();\n      });\n      claude.invoke.mockImplementation((): Promise<void> => {\n        callOrder.push('claude');\n        return Promise.resolve();\n      });\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(hooks.onPhaseStart).toHaveBeenCalledWith(\n        expect.objectContaining({ issue: 42, repo: 'org/repo' }),\n        'refine',\n      );\n      expect(callOrder).toEqual(['onPhaseStart', 'claude']);\n    });\n\n    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n\n      await expect(\n        service.route(makeEvent({ type: 'phase_deliver' })),\n      ).rejects.toThrow('Claude crash');\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should not cleanup worktree after normal phase execution', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('resolvePhase()', () => {\n    const task = makeTask();\n\n    it('should resolve issue_assigned to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve intake to refine', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n        'refine',\n      );\n    });\n\n    it('should resolve issue_comment to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n          task,\n        ),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve phase_* events to the phase name', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n      ).toBe('refine');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n      ).toBe('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_execute' }), task),\n      ).toBe('execute');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n      ).toBe('deliver');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n      ).toBe('compound');\n    });\n\n    it('should resolve _completed events to the next phase', () => {\n      internalAdapter.getNextPhase.mockReturnValue('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'refine_completed' }), task),\n      ).toBe('plan');\n      expect(internalAdapter.getNextPhase).toHaveBeenCalledWith('refine');\n    });\n\n    it('should resolve pr_merged to compound', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'pr_merged' }), task)).toBe(\n        'compound',\n      );\n    });\n\n    it('should resolve review_approved to null (handled specially in route)', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'review_approved' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve review_changes_requested to address_review', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'review_changes_requested' }),\n          task,\n        ),\n      ).toBe('address_review');\n    });\n\n    it('should resolve ci_passed to deliver', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'ci_passed' }), task)).toBe(\n        'deliver',\n      );\n    });\n\n    it('should resolve ci_failure to deliver', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'ci_failure' }), task),\n      ).toBe('deliver');\n    });\n\n    it('should resolve gate_released to the gated phase', () => {\n      const gatedTask = makeTask({ gatePhase: 'plan' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'gate_released' }), gatedTask),\n      ).toBe('plan');\n    });\n\n    it('should resolve pr_comment to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'pr_comment' }), task),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve task_cancelled to null', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_cancelled' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve task_reopened to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_reopened' }), task),\n      ).toBe('refine');\n    });\n\n    it('should return null for unknown events', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'something_unknown' }), task),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already blocked', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'refine',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), blockedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already gated', () => {\n      const gatedTask = makeTask({\n        status: 'gated',\n        currentPhase: 'review',\n        gatePhase: 'review',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), gatedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already past refine', () => {\n      const planTask = makeTask({ currentPhase: 'plan', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), planTask),\n      ).toBeNull();\n    });\n\n    it('should allow intake when task is still at refine', () => {\n      const refineTask = makeTask({ currentPhase: 'refine', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n      ).toBe('refine');\n    });\n\n    it('should resolve directive to gated phase for gated tasks', () => {\n      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n      ).toBe('review');\n    });\n\n    it('should return null for directive on non-gated tasks', () => {\n      const activeTask = makeTask({ status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n      ).toBeNull();\n    });\n\n    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('execute');\n    });\n\n    it('should resolve retry directive from event.directive field', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'retry' }),\n          stuckTask,\n        ),\n      ).toBe('plan');\n    });\n\n    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n          blockedTask,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve restart directive from event.directive field', () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'restart' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'execute' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via event.directive field', () => {\n      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should return null for retry on task with no currentPhase', () => {\n      const taskNoPhase = makeTask({\n        status: 'active',\n        currentPhase: null as unknown as string,\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          taskNoPhase,\n        ),\n      ).toBeNull();\n    });\n  });\n\n  // isGated() removed — gate logic moved to advanceAndEnqueue() in internal-adapter.\n  // Gate tests are now in internal-adapter.service.spec.ts.\n\n  // signal mapping tests removed — result handling moved to InvocationResultListener.\n\n  describe('issue_comment routing', () => {\n    it('should route issue_comment events to the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should route issue_comment with prNumber to reply_to_pr_comment with COMMENT_ID in env', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PHASE: 'reply_to_pr_comment',\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include PR_NUMBER from event when task has no prNumber', async () => {\n      const task = makeTask({ prNumber: null });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should not include AGENT_PERSONA in env for any phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('AGENT_PERSONA');\n    });\n  });\n\n  describe('review phase status on external review events', () => {\n    it('marks review phase complete when review_approved routes to deliver', async () => {\n      // Use quick directive so deliver is not gated and Claude gets invoked\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('marks review phase complete when review_changes_requested routes to address_review', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // address_review phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'review_changes_requested' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n\n    it('skips review phase status update when review is already complete', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('complete') // review status — already complete\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      // setPhaseStatus should NOT be called for review since it's already complete\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n  });\n\n  describe('event recording', () => {\n    it('should record phase_started event', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'phase_started',\n        'phase-router',\n        { phase: 'refine' },\n      );\n    });\n\n    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n    // after the phase completes, not by phase-router before execution.\n  });\n\n  describe('retry/restart directive handling', () => {\n    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'execute',\n        expect.objectContaining({ PHASE: 'execute' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {\n      const task = makeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n    });\n\n    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent retry',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Retry rejected'),\n      );\n    });\n\n    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent restart',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Restart rejected'),\n      );\n    });\n\n    it('unknown directive: posts feedback comment listing valid directives', async () => {\n      const task = makeTask({ status: 'active' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent foobar',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('retry'),\n      );\n    });\n  });\n\n  describe('quality gate override', () => {\n    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n        task.id,\n      );\n    });\n\n    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n    });\n\n    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        false,\n      );\n    });\n  });\n\n  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n\n  describe('idempotency guards', () => {\n    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'blocked',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'Awaiting approval',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        gatePhase: null,\n        gateReason: null,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent approve',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();\n    });\n\n    // artefact sync tests removed — artefact handling moved to InvocationResultListener.\n\n    it('should ignore intake event when task is already complete (late duplicate guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'compound',\n        status: 'complete',\n        phaseCompound: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    it('calls createTask once and invoke once when route() is called twice with same event', async () => {\n      const task = makeTask();\n      // First call: no task exists → create\n      // Second call: task now exists → reuse\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValue(task);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      const event = makeEvent({ type: 'issue_assigned' });\n      await service.route(event);\n      await service.route(event);\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Second call: phase is still 'pending' so invoke runs again (not prevented by active guard),\n      // but the idempotency contract for external events allows this. The important guarantee is\n      // createTask is not called twice.\n      expect(claude.invoke).toHaveBeenCalledTimes(2);\n    });\n\n    it('falls back to existing task when createTask throws unique-constraint error', async () => {\n      const existingTask = makeTask({ id: 5 });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null) // first call: no task\n        .mockResolvedValue(existingTask); // fallback + subsequent calls (paused check etc.)\n      taskState.createTask.mockRejectedValueOnce(\n        Object.assign(new Error('UNIQUE constraint failed'), {\n          code: 'SQLITE_CONSTRAINT',\n        }),\n      );\n      taskState.save.mockResolvedValue(existingTask);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_assigned' }));\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Falls back to the existing task and continues execution\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_ID: String(existingTask.id) }),\n        existingTask.id,\n      );\n    });\n\n    it('re-throws createTask error when no existing task can be found', async () => {\n      const error = Object.assign(new Error('DB error'), {\n        code: 'SOME_OTHER_ERROR',\n      });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValueOnce(null); // fallback also finds nothing\n      taskState.createTask.mockRejectedValueOnce(error);\n\n      await expect(\n        service.route(makeEvent({ type: 'issue_assigned' })),\n      ).rejects.toThrow('DB error');\n    });\n\n    it('skips phase_ event re-execution when phase is failed and task is gated (BLOCKED:PERSISTENT retry)', async () => {\n      const task = makeTask({\n        status: 'gated',\n        gatePhase: 'deliver',\n        gateReason: 'Max retries exceeded',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n\n      // BullMQ stall-retry re-delivers the phase_deliver job\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('allows phase_ re-execution when phase is failed but task is NOT gated (transient retry)', async () => {\n      // InternalAdapterService sets phase to 'failed' and re-enqueues on transient block;\n      // task.status stays 'active'. This legitimate retry must proceed.\n      const task = makeTask({\n        status: 'active',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n      // Phase status is 'active' in-memory on the task passed into executePhase.\n      // This simulates a scenario where route()'s active-phase check was bypassed.\n      const task = makeTask({\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('calls invoke exactly once when route() processes the same event while phase is active', async () => {\n      const task = makeTask({ phaseRefine: 'pending' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // First getPhaseStatus call (route guard): 'pending' → proceed\n      // executePhase() defensive guard also returns 'pending' → proceed to invoke\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      hooks.onPhaseComplete.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n","structuredPatch":[{"oldStart":803,"oldLines":9,"newStart":803,"newLines":6,"lines":["         service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),","       ).toBe('plan');","       expect(","-        service.resolvePhase(makeEvent({ type: 'phase_execute' }), task),","-      ).toBe('execute');","-      expect(","         service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),","       ).toBe('deliver');","       expect("]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01Ug6nxXPozNwNQ2qGg2nBvg","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01QeX7qVHRedP7NsJhRY5311","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","old_string":"    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('execute');","new_string":"    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('deliver');"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":564,"cache_read_input_tokens":99002,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":564},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"ba890229-aa1f-4ff1-8e02-c2a0497aad27"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01QeX7qVHRedP7NsJhRY5311","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"e53232ce-58c2-466f-9cc1-576c9bf51b1e","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","oldString":"    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('execute');","newString":"    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('deliver');","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { PhaseRouterService } from './phase-router.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\nimport { ClaudeInvocationService } from '../invoke/claude-invocation.service';\nimport { WorktreeService } from '../worktree/worktree.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventService } from '../event/event.service';\nimport { GitHubService } from '../github/github.service';\nimport { TokenUsageService } from '../token-usage/token-usage.service';\nimport { ReleaseService } from '../release/release.service';\nimport { EventBusService } from '../event-bus';\nimport type { TaskEntity } from '../database/entities/task.entity';\nimport type { DispatchEvent } from '../queue/job-queue.interface';\n\nfunction makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n  return {\n    id: 1,\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    currentPhase: 'refine',\n    status: 'active',\n    gatePhase: null,\n    gateReason: null,\n    prNumber: null,\n    prBranch: null,\n    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseReview: 'pending',\n    phaseDeliver: 'pending',\n    phaseCompound: 'pending',\n    qualityGateOverride: false,\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    ...overrides,\n  } as TaskEntity;\n}\n\nfunction makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n  return {\n    type: 'issue_assigned',\n    issueNumber: 42,\n    repo: 'org/repo',\n    ...overrides,\n  };\n}\n\ndescribe('PhaseRouterService', () => {\n  let service: PhaseRouterService;\n  let taskState: jest.Mocked<TaskStateService>;\n  let internalAdapter: jest.Mocked<InternalAdapterService>;\n  let claude: jest.Mocked<ClaudeInvocationService>;\n  let worktreeService: jest.Mocked<WorktreeService>;\n  let artefacts: jest.Mocked<ArtefactsService>;\n  let hooks: jest.Mocked<PhaseHooksService>;\n  let eventService: jest.Mocked<EventService>;\n  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let releaseService: jest.Mocked<\n    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n  >;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        PhaseRouterService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByIssueAndRepo: jest.fn(),\n            createTask: jest.fn(),\n            save: jest.fn(),\n            getPhaseStatus: jest.fn(),\n            setPhaseStatus: jest.fn(),\n            setGate: jest.fn(),\n            clearGate: jest.fn(),\n            resetForRetry: jest.fn(),\n            resetForRestart: jest.fn(),\n            setQualityGateOverride: jest.fn(),\n            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: InternalAdapterService,\n          useValue: {\n            handleSignal: jest.fn(),\n            getNextPhase: jest.fn(),\n          },\n        },\n        {\n          provide: ClaudeInvocationService,\n          useValue: {\n            invoke: jest.fn(),\n          },\n        },\n        {\n          provide: WorktreeService,\n          useValue: {\n            getOrCreateWorktree: jest.fn(),\n            cleanupWorktree: jest.fn(),\n          },\n        },\n        {\n          provide: ArtefactsService,\n          useValue: {\n            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n            buildCentralTaskDir: jest\n              .fn()\n              .mockReturnValue(\n                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n              ),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n            onPhaseComplete: jest.fn(),\n            onPhaseSkip: jest.fn(),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn(),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: GitHubService,\n          useValue: {\n            postComment: jest.fn().mockResolvedValue(undefined),\n            getPRFiles: jest.fn().mockResolvedValue([]),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: TokenUsageService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get(PhaseRouterService);\n    taskState = module.get(TaskStateService);\n    internalAdapter = module.get(InternalAdapterService);\n    claude = module.get(ClaudeInvocationService);\n    worktreeService = module.get(WorktreeService);\n    artefacts = module.get(ArtefactsService);\n    hooks = module.get(PhaseHooksService);\n    eventService = module.get(EventService);\n    gitHub = module.get(GitHubService);\n    eventBus = module.get(EventBusService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  describe('route()', () => {\n    it('should create a new task when none exists and execute the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({\n          ISSUE_NUMBER: '42',\n          REPO: 'org/repo',\n          PHASE: 'refine',\n        }),\n        task.id,\n      );\n    });\n\n    it('should use existing task when found', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should use the directive from the event when creating a task', async () => {\n      const task = makeTask({ directive: 'careful' });\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ directive: 'careful' }));\n\n      expect(taskState.createTask).toHaveBeenCalledWith(\n        42,\n        'org/repo',\n        'careful',\n      );\n    });\n\n    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n      const task = makeTask({ directive: 'careful', currentPhase: 'plan' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      // Plan runs — gate is applied AFTER completion in advanceAndEnqueue, not before\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n      expect(taskState.setGate).not.toHaveBeenCalled();\n    });\n\n    it('should not gate before phase execution (gates are post-completion in internal-adapter)', async () => {\n      const task = makeTask({ directive: 'auto' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should not gate any phase with quick directive', async () => {\n      const task = makeTask({ directive: 'quick' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip when event type cannot be resolved to a phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'unknown_event' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip execution when phase is already active (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should execute phase when phase status is pending (first run)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip completed phase on internal phase_* event (dedup guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip skipped phase on internal phase_* event', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'skipped',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('skipped');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip phase execution when task has been paused by /agent stop', async () => {\n      // Simulate: task.status='paused' was set in DB while a phase_plan\n      // completion event was already in the queue.  The fresh DB read must win.\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'paused',\n        phasePlan: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent stop',\n          actor: 'zhfahan77',\n        }),\n      );\n\n      expect(taskState.save).toHaveBeenCalledWith(\n        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_paused',\n        'phase-router',\n        { actor: 'zhfahan77' },\n      );\n    });\n\n    it('should allow re-executing completed refine on issue_comment event', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_cancelled',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle task_cancelled with no existing task gracefully', async () => {\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(taskState.save).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockRejectedValue(\n        new Error('worktree not found'),\n      );\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      // Should not throw — cleanup failure is best-effort\n    });\n\n    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_reopened',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should clear gate and execute phase on directive event', async () => {\n      const task = makeTask({\n        status: 'gated',\n        directive: 'quick',\n        currentPhase: 'review',\n        gatePhase: 'review',\n        gateReason: 'Awaiting approval',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: 'approve' }),\n      );\n\n      expect(task.status).toBe('active');\n      expect(task.gatePhase).toBeNull();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'gate_released',\n        'phase-router',\n        expect.objectContaining({ directive: 'approve' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({ PHASE: 'review' }),\n        task.id,\n      );\n    });\n  });\n\n  describe('executePhase() via route()', () => {\n    it('should create a worktree for the deliver phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/worktree' }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree for the address_review phase', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix-review' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_address_review' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'address_review',\n        expect.objectContaining({\n          WORKTREE_PATH: '/tmp/worktree',\n          PR_NUMBER: '50',\n          PR_BRANCH: 'feat/fix-review',\n        }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree with agents-md prefix for compound phase', async () => {\n      const task = makeTask({ currentPhase: 'compound' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue(\n        '/tmp/compound-worktree',\n      );\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_compound' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'agents-md',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'compound',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/compound-worktree' }),\n        task.id,\n      );\n    });\n\n    it('should pass rcBranch to getOrCreateWorktree when an active release exists', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue({\n        releaseId: 7,\n        key: 'RC-1',\n        title: 'Release 1',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v1.2',\n        baseBranch: 'master',\n      });\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        'rc/v1.2',\n      );\n    });\n\n    it('should pass undefined baseBranch to getOrCreateWorktree when no release is active', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue(null);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n    });\n\n    it('should not create a worktree for non-execute phases', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(worktreeService.getOrCreateWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree creation failure with transient block', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockRejectedValue(\n        new Error('disk full'),\n      );\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(internalAdapter.handleSignal).toHaveBeenCalledWith(\n        task.id,\n        'deliver',\n        'BLOCKED:TRANSIENT',\n        expect.stringContaining('disk full'),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should set phase status to active before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'plan',\n        'active',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('should include PR env vars when task has prNumber and prBranch', async () => {\n      const task = makeTask({\n        prNumber: 99,\n        prBranch: 'feat/test',\n        directive: 'quick',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_review' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({\n          PR_NUMBER: '99',\n          PR_BRANCH: 'feat/test',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include TASK_DIR env var pointing to central path', async () => {\n      const centralPath =\n        '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42';\n      artefacts.buildCentralTaskDir.mockReturnValue(centralPath);\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(artefacts.buildCentralTaskDir).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_DIR: centralPath }),\n        task.id,\n      );\n    });\n\n    it('should call onPhaseStart before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      const callOrder: string[] = [];\n      hooks.onPhaseStart.mockImplementation((): Promise<void> => {\n        callOrder.push('onPhaseStart');\n        return Promise.resolve();\n      });\n      claude.invoke.mockImplementation((): Promise<void> => {\n        callOrder.push('claude');\n        return Promise.resolve();\n      });\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(hooks.onPhaseStart).toHaveBeenCalledWith(\n        expect.objectContaining({ issue: 42, repo: 'org/repo' }),\n        'refine',\n      );\n      expect(callOrder).toEqual(['onPhaseStart', 'claude']);\n    });\n\n    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n\n      await expect(\n        service.route(makeEvent({ type: 'phase_deliver' })),\n      ).rejects.toThrow('Claude crash');\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should not cleanup worktree after normal phase execution', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('resolvePhase()', () => {\n    const task = makeTask();\n\n    it('should resolve issue_assigned to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve intake to refine', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n        'refine',\n      );\n    });\n\n    it('should resolve issue_comment to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n          task,\n        ),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve phase_* events to the phase name', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n      ).toBe('refine');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n      ).toBe('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n      ).toBe('deliver');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n      ).toBe('compound');\n    });\n\n    it('should resolve _completed events to the next phase', () => {\n      internalAdapter.getNextPhase.mockReturnValue('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'refine_completed' }), task),\n      ).toBe('plan');\n      expect(internalAdapter.getNextPhase).toHaveBeenCalledWith('refine');\n    });\n\n    it('should resolve pr_merged to compound', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'pr_merged' }), task)).toBe(\n        'compound',\n      );\n    });\n\n    it('should resolve review_approved to null (handled specially in route)', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'review_approved' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve review_changes_requested to address_review', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'review_changes_requested' }),\n          task,\n        ),\n      ).toBe('address_review');\n    });\n\n    it('should resolve ci_passed to deliver', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'ci_passed' }), task)).toBe(\n        'deliver',\n      );\n    });\n\n    it('should resolve ci_failure to deliver', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'ci_failure' }), task),\n      ).toBe('deliver');\n    });\n\n    it('should resolve gate_released to the gated phase', () => {\n      const gatedTask = makeTask({ gatePhase: 'plan' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'gate_released' }), gatedTask),\n      ).toBe('plan');\n    });\n\n    it('should resolve pr_comment to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'pr_comment' }), task),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve task_cancelled to null', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_cancelled' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve task_reopened to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_reopened' }), task),\n      ).toBe('refine');\n    });\n\n    it('should return null for unknown events', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'something_unknown' }), task),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already blocked', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'refine',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), blockedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already gated', () => {\n      const gatedTask = makeTask({\n        status: 'gated',\n        currentPhase: 'review',\n        gatePhase: 'review',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), gatedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already past refine', () => {\n      const planTask = makeTask({ currentPhase: 'plan', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), planTask),\n      ).toBeNull();\n    });\n\n    it('should allow intake when task is still at refine', () => {\n      const refineTask = makeTask({ currentPhase: 'refine', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n      ).toBe('refine');\n    });\n\n    it('should resolve directive to gated phase for gated tasks', () => {\n      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n      ).toBe('review');\n    });\n\n    it('should return null for directive on non-gated tasks', () => {\n      const activeTask = makeTask({ status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n      ).toBeNull();\n    });\n\n    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('execute');\n    });\n\n    it('should resolve retry directive from event.directive field', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'retry' }),\n          stuckTask,\n        ),\n      ).toBe('plan');\n    });\n\n    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n          blockedTask,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve restart directive from event.directive field', () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'restart' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'execute' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via event.directive field', () => {\n      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should return null for retry on task with no currentPhase', () => {\n      const taskNoPhase = makeTask({\n        status: 'active',\n        currentPhase: null as unknown as string,\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          taskNoPhase,\n        ),\n      ).toBeNull();\n    });\n  });\n\n  // isGated() removed — gate logic moved to advanceAndEnqueue() in internal-adapter.\n  // Gate tests are now in internal-adapter.service.spec.ts.\n\n  // signal mapping tests removed — result handling moved to InvocationResultListener.\n\n  describe('issue_comment routing', () => {\n    it('should route issue_comment events to the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should route issue_comment with prNumber to reply_to_pr_comment with COMMENT_ID in env', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PHASE: 'reply_to_pr_comment',\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include PR_NUMBER from event when task has no prNumber', async () => {\n      const task = makeTask({ prNumber: null });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should not include AGENT_PERSONA in env for any phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('AGENT_PERSONA');\n    });\n  });\n\n  describe('review phase status on external review events', () => {\n    it('marks review phase complete when review_approved routes to deliver', async () => {\n      // Use quick directive so deliver is not gated and Claude gets invoked\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('marks review phase complete when review_changes_requested routes to address_review', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // address_review phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'review_changes_requested' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n\n    it('skips review phase status update when review is already complete', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('complete') // review status — already complete\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      // setPhaseStatus should NOT be called for review since it's already complete\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n  });\n\n  describe('event recording', () => {\n    it('should record phase_started event', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'phase_started',\n        'phase-router',\n        { phase: 'refine' },\n      );\n    });\n\n    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n    // after the phase completes, not by phase-router before execution.\n  });\n\n  describe('retry/restart directive handling', () => {\n    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'execute',\n        expect.objectContaining({ PHASE: 'execute' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {\n      const task = makeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n    });\n\n    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent retry',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Retry rejected'),\n      );\n    });\n\n    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent restart',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Restart rejected'),\n      );\n    });\n\n    it('unknown directive: posts feedback comment listing valid directives', async () => {\n      const task = makeTask({ status: 'active' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent foobar',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('retry'),\n      );\n    });\n  });\n\n  describe('quality gate override', () => {\n    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n        task.id,\n      );\n    });\n\n    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n    });\n\n    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        false,\n      );\n    });\n  });\n\n  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n\n  describe('idempotency guards', () => {\n    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'blocked',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'Awaiting approval',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        gatePhase: null,\n        gateReason: null,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent approve',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();\n    });\n\n    // artefact sync tests removed — artefact handling moved to InvocationResultListener.\n\n    it('should ignore intake event when task is already complete (late duplicate guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'compound',\n        status: 'complete',\n        phaseCompound: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    it('calls createTask once and invoke once when route() is called twice with same event', async () => {\n      const task = makeTask();\n      // First call: no task exists → create\n      // Second call: task now exists → reuse\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValue(task);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      const event = makeEvent({ type: 'issue_assigned' });\n      await service.route(event);\n      await service.route(event);\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Second call: phase is still 'pending' so invoke runs again (not prevented by active guard),\n      // but the idempotency contract for external events allows this. The important guarantee is\n      // createTask is not called twice.\n      expect(claude.invoke).toHaveBeenCalledTimes(2);\n    });\n\n    it('falls back to existing task when createTask throws unique-constraint error', async () => {\n      const existingTask = makeTask({ id: 5 });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null) // first call: no task\n        .mockResolvedValue(existingTask); // fallback + subsequent calls (paused check etc.)\n      taskState.createTask.mockRejectedValueOnce(\n        Object.assign(new Error('UNIQUE constraint failed'), {\n          code: 'SQLITE_CONSTRAINT',\n        }),\n      );\n      taskState.save.mockResolvedValue(existingTask);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_assigned' }));\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Falls back to the existing task and continues execution\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_ID: String(existingTask.id) }),\n        existingTask.id,\n      );\n    });\n\n    it('re-throws createTask error when no existing task can be found', async () => {\n      const error = Object.assign(new Error('DB error'), {\n        code: 'SOME_OTHER_ERROR',\n      });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValueOnce(null); // fallback also finds nothing\n      taskState.createTask.mockRejectedValueOnce(error);\n\n      await expect(\n        service.route(makeEvent({ type: 'issue_assigned' })),\n      ).rejects.toThrow('DB error');\n    });\n\n    it('skips phase_ event re-execution when phase is failed and task is gated (BLOCKED:PERSISTENT retry)', async () => {\n      const task = makeTask({\n        status: 'gated',\n        gatePhase: 'deliver',\n        gateReason: 'Max retries exceeded',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n\n      // BullMQ stall-retry re-delivers the phase_deliver job\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('allows phase_ re-execution when phase is failed but task is NOT gated (transient retry)', async () => {\n      // InternalAdapterService sets phase to 'failed' and re-enqueues on transient block;\n      // task.status stays 'active'. This legitimate retry must proceed.\n      const task = makeTask({\n        status: 'active',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n      // Phase status is 'active' in-memory on the task passed into executePhase.\n      // This simulates a scenario where route()'s active-phase check was bypassed.\n      const task = makeTask({\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('calls invoke exactly once when route() processes the same event while phase is active', async () => {\n      const task = makeTask({ phaseRefine: 'pending' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // First getPhaseStatus call (route guard): 'pending' → proceed\n      // executePhase() defensive guard also returns 'pending' → proceed to invoke\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      hooks.onPhaseComplete.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n","structuredPatch":[{"oldStart":934,"oldLines":14,"newStart":934,"newLines":14,"lines":["     it('should resolve retry directive to currentPhase', () => {","       const stuckTask = makeTask({","         status: 'stuck',","-        currentPhase: 'execute',","+        currentPhase: 'deliver',","       });","       expect(","         service.resolvePhase(","           makeEvent({ type: 'directive', commentBody: '/agent retry' }),","           stuckTask,","         ),","-      ).toBe('execute');","+      ).toBe('deliver');","     });"," ","     it('should resolve retry directive from event.directive field', () => {"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01EKepyArcVp5Tm2Cknn7gRh","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01A9YqnFswUX4xsKejE5psTA","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","old_string":"    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n      });","new_string":"    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'deliver',\n      });"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":505,"cache_read_input_tokens":99566,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":505},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"3298a203-f69b-4f99-9fef-30afb1bdf897"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01A9YqnFswUX4xsKejE5psTA","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"15b72ea1-cdf3-422b-a8d8-ea2b45218d6c","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","oldString":"    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n      });","newString":"    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'deliver',\n      });","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { PhaseRouterService } from './phase-router.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\nimport { ClaudeInvocationService } from '../invoke/claude-invocation.service';\nimport { WorktreeService } from '../worktree/worktree.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventService } from '../event/event.service';\nimport { GitHubService } from '../github/github.service';\nimport { TokenUsageService } from '../token-usage/token-usage.service';\nimport { ReleaseService } from '../release/release.service';\nimport { EventBusService } from '../event-bus';\nimport type { TaskEntity } from '../database/entities/task.entity';\nimport type { DispatchEvent } from '../queue/job-queue.interface';\n\nfunction makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n  return {\n    id: 1,\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    currentPhase: 'refine',\n    status: 'active',\n    gatePhase: null,\n    gateReason: null,\n    prNumber: null,\n    prBranch: null,\n    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseReview: 'pending',\n    phaseDeliver: 'pending',\n    phaseCompound: 'pending',\n    qualityGateOverride: false,\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    ...overrides,\n  } as TaskEntity;\n}\n\nfunction makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n  return {\n    type: 'issue_assigned',\n    issueNumber: 42,\n    repo: 'org/repo',\n    ...overrides,\n  };\n}\n\ndescribe('PhaseRouterService', () => {\n  let service: PhaseRouterService;\n  let taskState: jest.Mocked<TaskStateService>;\n  let internalAdapter: jest.Mocked<InternalAdapterService>;\n  let claude: jest.Mocked<ClaudeInvocationService>;\n  let worktreeService: jest.Mocked<WorktreeService>;\n  let artefacts: jest.Mocked<ArtefactsService>;\n  let hooks: jest.Mocked<PhaseHooksService>;\n  let eventService: jest.Mocked<EventService>;\n  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let releaseService: jest.Mocked<\n    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n  >;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        PhaseRouterService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByIssueAndRepo: jest.fn(),\n            createTask: jest.fn(),\n            save: jest.fn(),\n            getPhaseStatus: jest.fn(),\n            setPhaseStatus: jest.fn(),\n            setGate: jest.fn(),\n            clearGate: jest.fn(),\n            resetForRetry: jest.fn(),\n            resetForRestart: jest.fn(),\n            setQualityGateOverride: jest.fn(),\n            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: InternalAdapterService,\n          useValue: {\n            handleSignal: jest.fn(),\n            getNextPhase: jest.fn(),\n          },\n        },\n        {\n          provide: ClaudeInvocationService,\n          useValue: {\n            invoke: jest.fn(),\n          },\n        },\n        {\n          provide: WorktreeService,\n          useValue: {\n            getOrCreateWorktree: jest.fn(),\n            cleanupWorktree: jest.fn(),\n          },\n        },\n        {\n          provide: ArtefactsService,\n          useValue: {\n            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n            buildCentralTaskDir: jest\n              .fn()\n              .mockReturnValue(\n                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n              ),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n            onPhaseComplete: jest.fn(),\n            onPhaseSkip: jest.fn(),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn(),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: GitHubService,\n          useValue: {\n            postComment: jest.fn().mockResolvedValue(undefined),\n            getPRFiles: jest.fn().mockResolvedValue([]),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: TokenUsageService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get(PhaseRouterService);\n    taskState = module.get(TaskStateService);\n    internalAdapter = module.get(InternalAdapterService);\n    claude = module.get(ClaudeInvocationService);\n    worktreeService = module.get(WorktreeService);\n    artefacts = module.get(ArtefactsService);\n    hooks = module.get(PhaseHooksService);\n    eventService = module.get(EventService);\n    gitHub = module.get(GitHubService);\n    eventBus = module.get(EventBusService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  describe('route()', () => {\n    it('should create a new task when none exists and execute the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({\n          ISSUE_NUMBER: '42',\n          REPO: 'org/repo',\n          PHASE: 'refine',\n        }),\n        task.id,\n      );\n    });\n\n    it('should use existing task when found', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should use the directive from the event when creating a task', async () => {\n      const task = makeTask({ directive: 'careful' });\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ directive: 'careful' }));\n\n      expect(taskState.createTask).toHaveBeenCalledWith(\n        42,\n        'org/repo',\n        'careful',\n      );\n    });\n\n    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n      const task = makeTask({ directive: 'careful', currentPhase: 'plan' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      // Plan runs — gate is applied AFTER completion in advanceAndEnqueue, not before\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n      expect(taskState.setGate).not.toHaveBeenCalled();\n    });\n\n    it('should not gate before phase execution (gates are post-completion in internal-adapter)', async () => {\n      const task = makeTask({ directive: 'auto' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should not gate any phase with quick directive', async () => {\n      const task = makeTask({ directive: 'quick' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip when event type cannot be resolved to a phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'unknown_event' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip execution when phase is already active (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should execute phase when phase status is pending (first run)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip completed phase on internal phase_* event (dedup guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip skipped phase on internal phase_* event', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'skipped',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('skipped');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip phase execution when task has been paused by /agent stop', async () => {\n      // Simulate: task.status='paused' was set in DB while a phase_plan\n      // completion event was already in the queue.  The fresh DB read must win.\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'paused',\n        phasePlan: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent stop',\n          actor: 'zhfahan77',\n        }),\n      );\n\n      expect(taskState.save).toHaveBeenCalledWith(\n        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_paused',\n        'phase-router',\n        { actor: 'zhfahan77' },\n      );\n    });\n\n    it('should allow re-executing completed refine on issue_comment event', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_cancelled',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle task_cancelled with no existing task gracefully', async () => {\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(taskState.save).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockRejectedValue(\n        new Error('worktree not found'),\n      );\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      // Should not throw — cleanup failure is best-effort\n    });\n\n    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_reopened',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should clear gate and execute phase on directive event', async () => {\n      const task = makeTask({\n        status: 'gated',\n        directive: 'quick',\n        currentPhase: 'review',\n        gatePhase: 'review',\n        gateReason: 'Awaiting approval',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: 'approve' }),\n      );\n\n      expect(task.status).toBe('active');\n      expect(task.gatePhase).toBeNull();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'gate_released',\n        'phase-router',\n        expect.objectContaining({ directive: 'approve' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({ PHASE: 'review' }),\n        task.id,\n      );\n    });\n  });\n\n  describe('executePhase() via route()', () => {\n    it('should create a worktree for the deliver phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/worktree' }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree for the address_review phase', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix-review' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_address_review' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'address_review',\n        expect.objectContaining({\n          WORKTREE_PATH: '/tmp/worktree',\n          PR_NUMBER: '50',\n          PR_BRANCH: 'feat/fix-review',\n        }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree with agents-md prefix for compound phase', async () => {\n      const task = makeTask({ currentPhase: 'compound' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue(\n        '/tmp/compound-worktree',\n      );\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_compound' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'agents-md',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'compound',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/compound-worktree' }),\n        task.id,\n      );\n    });\n\n    it('should pass rcBranch to getOrCreateWorktree when an active release exists', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue({\n        releaseId: 7,\n        key: 'RC-1',\n        title: 'Release 1',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v1.2',\n        baseBranch: 'master',\n      });\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        'rc/v1.2',\n      );\n    });\n\n    it('should pass undefined baseBranch to getOrCreateWorktree when no release is active', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue(null);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n    });\n\n    it('should not create a worktree for non-execute phases', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(worktreeService.getOrCreateWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree creation failure with transient block', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockRejectedValue(\n        new Error('disk full'),\n      );\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(internalAdapter.handleSignal).toHaveBeenCalledWith(\n        task.id,\n        'deliver',\n        'BLOCKED:TRANSIENT',\n        expect.stringContaining('disk full'),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should set phase status to active before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'plan',\n        'active',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('should include PR env vars when task has prNumber and prBranch', async () => {\n      const task = makeTask({\n        prNumber: 99,\n        prBranch: 'feat/test',\n        directive: 'quick',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_review' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({\n          PR_NUMBER: '99',\n          PR_BRANCH: 'feat/test',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include TASK_DIR env var pointing to central path', async () => {\n      const centralPath =\n        '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42';\n      artefacts.buildCentralTaskDir.mockReturnValue(centralPath);\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(artefacts.buildCentralTaskDir).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_DIR: centralPath }),\n        task.id,\n      );\n    });\n\n    it('should call onPhaseStart before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      const callOrder: string[] = [];\n      hooks.onPhaseStart.mockImplementation((): Promise<void> => {\n        callOrder.push('onPhaseStart');\n        return Promise.resolve();\n      });\n      claude.invoke.mockImplementation((): Promise<void> => {\n        callOrder.push('claude');\n        return Promise.resolve();\n      });\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(hooks.onPhaseStart).toHaveBeenCalledWith(\n        expect.objectContaining({ issue: 42, repo: 'org/repo' }),\n        'refine',\n      );\n      expect(callOrder).toEqual(['onPhaseStart', 'claude']);\n    });\n\n    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n\n      await expect(\n        service.route(makeEvent({ type: 'phase_deliver' })),\n      ).rejects.toThrow('Claude crash');\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should not cleanup worktree after normal phase execution', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('resolvePhase()', () => {\n    const task = makeTask();\n\n    it('should resolve issue_assigned to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve intake to refine', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n        'refine',\n      );\n    });\n\n    it('should resolve issue_comment to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n          task,\n        ),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve phase_* events to the phase name', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n      ).toBe('refine');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n      ).toBe('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n      ).toBe('deliver');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n      ).toBe('compound');\n    });\n\n    it('should resolve _completed events to the next phase', () => {\n      internalAdapter.getNextPhase.mockReturnValue('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'refine_completed' }), task),\n      ).toBe('plan');\n      expect(internalAdapter.getNextPhase).toHaveBeenCalledWith('refine');\n    });\n\n    it('should resolve pr_merged to compound', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'pr_merged' }), task)).toBe(\n        'compound',\n      );\n    });\n\n    it('should resolve review_approved to null (handled specially in route)', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'review_approved' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve review_changes_requested to address_review', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'review_changes_requested' }),\n          task,\n        ),\n      ).toBe('address_review');\n    });\n\n    it('should resolve ci_passed to deliver', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'ci_passed' }), task)).toBe(\n        'deliver',\n      );\n    });\n\n    it('should resolve ci_failure to deliver', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'ci_failure' }), task),\n      ).toBe('deliver');\n    });\n\n    it('should resolve gate_released to the gated phase', () => {\n      const gatedTask = makeTask({ gatePhase: 'plan' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'gate_released' }), gatedTask),\n      ).toBe('plan');\n    });\n\n    it('should resolve pr_comment to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'pr_comment' }), task),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve task_cancelled to null', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_cancelled' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve task_reopened to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_reopened' }), task),\n      ).toBe('refine');\n    });\n\n    it('should return null for unknown events', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'something_unknown' }), task),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already blocked', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'refine',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), blockedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already gated', () => {\n      const gatedTask = makeTask({\n        status: 'gated',\n        currentPhase: 'review',\n        gatePhase: 'review',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), gatedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already past refine', () => {\n      const planTask = makeTask({ currentPhase: 'plan', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), planTask),\n      ).toBeNull();\n    });\n\n    it('should allow intake when task is still at refine', () => {\n      const refineTask = makeTask({ currentPhase: 'refine', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n      ).toBe('refine');\n    });\n\n    it('should resolve directive to gated phase for gated tasks', () => {\n      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n      ).toBe('review');\n    });\n\n    it('should return null for directive on non-gated tasks', () => {\n      const activeTask = makeTask({ status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n      ).toBeNull();\n    });\n\n    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('deliver');\n    });\n\n    it('should resolve retry directive from event.directive field', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'retry' }),\n          stuckTask,\n        ),\n      ).toBe('plan');\n    });\n\n    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n          blockedTask,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve restart directive from event.directive field', () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'restart' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'execute' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via event.directive field', () => {\n      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should return null for retry on task with no currentPhase', () => {\n      const taskNoPhase = makeTask({\n        status: 'active',\n        currentPhase: null as unknown as string,\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          taskNoPhase,\n        ),\n      ).toBeNull();\n    });\n  });\n\n  // isGated() removed — gate logic moved to advanceAndEnqueue() in internal-adapter.\n  // Gate tests are now in internal-adapter.service.spec.ts.\n\n  // signal mapping tests removed — result handling moved to InvocationResultListener.\n\n  describe('issue_comment routing', () => {\n    it('should route issue_comment events to the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should route issue_comment with prNumber to reply_to_pr_comment with COMMENT_ID in env', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PHASE: 'reply_to_pr_comment',\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include PR_NUMBER from event when task has no prNumber', async () => {\n      const task = makeTask({ prNumber: null });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should not include AGENT_PERSONA in env for any phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('AGENT_PERSONA');\n    });\n  });\n\n  describe('review phase status on external review events', () => {\n    it('marks review phase complete when review_approved routes to deliver', async () => {\n      // Use quick directive so deliver is not gated and Claude gets invoked\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('marks review phase complete when review_changes_requested routes to address_review', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // address_review phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'review_changes_requested' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n\n    it('skips review phase status update when review is already complete', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('complete') // review status — already complete\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      // setPhaseStatus should NOT be called for review since it's already complete\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n  });\n\n  describe('event recording', () => {\n    it('should record phase_started event', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'phase_started',\n        'phase-router',\n        { phase: 'refine' },\n      );\n    });\n\n    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n    // after the phase completes, not by phase-router before execution.\n  });\n\n  describe('retry/restart directive handling', () => {\n    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'execute',\n        expect.objectContaining({ PHASE: 'execute' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {\n      const task = makeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n    });\n\n    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent retry',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Retry rejected'),\n      );\n    });\n\n    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent restart',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Restart rejected'),\n      );\n    });\n\n    it('unknown directive: posts feedback comment listing valid directives', async () => {\n      const task = makeTask({ status: 'active' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent foobar',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('retry'),\n      );\n    });\n  });\n\n  describe('quality gate override', () => {\n    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n        task.id,\n      );\n    });\n\n    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n    });\n\n    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        false,\n      );\n    });\n  });\n\n  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n\n  describe('idempotency guards', () => {\n    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'blocked',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'Awaiting approval',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        gatePhase: null,\n        gateReason: null,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent approve',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();\n    });\n\n    // artefact sync tests removed — artefact handling moved to InvocationResultListener.\n\n    it('should ignore intake event when task is already complete (late duplicate guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'compound',\n        status: 'complete',\n        phaseCompound: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    it('calls createTask once and invoke once when route() is called twice with same event', async () => {\n      const task = makeTask();\n      // First call: no task exists → create\n      // Second call: task now exists → reuse\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValue(task);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      const event = makeEvent({ type: 'issue_assigned' });\n      await service.route(event);\n      await service.route(event);\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Second call: phase is still 'pending' so invoke runs again (not prevented by active guard),\n      // but the idempotency contract for external events allows this. The important guarantee is\n      // createTask is not called twice.\n      expect(claude.invoke).toHaveBeenCalledTimes(2);\n    });\n\n    it('falls back to existing task when createTask throws unique-constraint error', async () => {\n      const existingTask = makeTask({ id: 5 });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null) // first call: no task\n        .mockResolvedValue(existingTask); // fallback + subsequent calls (paused check etc.)\n      taskState.createTask.mockRejectedValueOnce(\n        Object.assign(new Error('UNIQUE constraint failed'), {\n          code: 'SQLITE_CONSTRAINT',\n        }),\n      );\n      taskState.save.mockResolvedValue(existingTask);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_assigned' }));\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Falls back to the existing task and continues execution\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_ID: String(existingTask.id) }),\n        existingTask.id,\n      );\n    });\n\n    it('re-throws createTask error when no existing task can be found', async () => {\n      const error = Object.assign(new Error('DB error'), {\n        code: 'SOME_OTHER_ERROR',\n      });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValueOnce(null); // fallback also finds nothing\n      taskState.createTask.mockRejectedValueOnce(error);\n\n      await expect(\n        service.route(makeEvent({ type: 'issue_assigned' })),\n      ).rejects.toThrow('DB error');\n    });\n\n    it('skips phase_ event re-execution when phase is failed and task is gated (BLOCKED:PERSISTENT retry)', async () => {\n      const task = makeTask({\n        status: 'gated',\n        gatePhase: 'deliver',\n        gateReason: 'Max retries exceeded',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n\n      // BullMQ stall-retry re-delivers the phase_deliver job\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('allows phase_ re-execution when phase is failed but task is NOT gated (transient retry)', async () => {\n      // InternalAdapterService sets phase to 'failed' and re-enqueues on transient block;\n      // task.status stays 'active'. This legitimate retry must proceed.\n      const task = makeTask({\n        status: 'active',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n      // Phase status is 'active' in-memory on the task passed into executePhase.\n      // This simulates a scenario where route()'s active-phase check was bypassed.\n      const task = makeTask({\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('calls invoke exactly once when route() processes the same event while phase is active', async () => {\n      const task = makeTask({ phaseRefine: 'pending' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // First getPhaseStatus call (route guard): 'pending' → proceed\n      // executePhase() defensive guard also returns 'pending' → proceed to invoke\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      hooks.onPhaseComplete.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n","structuredPatch":[{"oldStart":960,"oldLines":7,"newStart":960,"newLines":7,"lines":["     it('should resolve restart directive to refine regardless of current phase', () => {","       const blockedTask = makeTask({","         status: 'blocked',","-        currentPhase: 'execute',","+        currentPhase: 'deliver',","       });","       expect(","         service.resolvePhase("]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01BWQyVHgfUokJsMusQ4CXNG","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DixYwuru7tJzWZjNrddhSr","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","old_string":"    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'execute' });","new_string":"    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'deliver' });"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":309,"cache_read_input_tokens":100071,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":309},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"da72279b-ee06-4620-9469-9d07341a902b"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DixYwuru7tJzWZjNrddhSr","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"1a9735db-1f05-4d07-8328-485c5691412b","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","oldString":"    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'execute' });","newString":"    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'deliver' });","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { PhaseRouterService } from './phase-router.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\nimport { ClaudeInvocationService } from '../invoke/claude-invocation.service';\nimport { WorktreeService } from '../worktree/worktree.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventService } from '../event/event.service';\nimport { GitHubService } from '../github/github.service';\nimport { TokenUsageService } from '../token-usage/token-usage.service';\nimport { ReleaseService } from '../release/release.service';\nimport { EventBusService } from '../event-bus';\nimport type { TaskEntity } from '../database/entities/task.entity';\nimport type { DispatchEvent } from '../queue/job-queue.interface';\n\nfunction makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n  return {\n    id: 1,\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    currentPhase: 'refine',\n    status: 'active',\n    gatePhase: null,\n    gateReason: null,\n    prNumber: null,\n    prBranch: null,\n    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseReview: 'pending',\n    phaseDeliver: 'pending',\n    phaseCompound: 'pending',\n    qualityGateOverride: false,\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    ...overrides,\n  } as TaskEntity;\n}\n\nfunction makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n  return {\n    type: 'issue_assigned',\n    issueNumber: 42,\n    repo: 'org/repo',\n    ...overrides,\n  };\n}\n\ndescribe('PhaseRouterService', () => {\n  let service: PhaseRouterService;\n  let taskState: jest.Mocked<TaskStateService>;\n  let internalAdapter: jest.Mocked<InternalAdapterService>;\n  let claude: jest.Mocked<ClaudeInvocationService>;\n  let worktreeService: jest.Mocked<WorktreeService>;\n  let artefacts: jest.Mocked<ArtefactsService>;\n  let hooks: jest.Mocked<PhaseHooksService>;\n  let eventService: jest.Mocked<EventService>;\n  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let releaseService: jest.Mocked<\n    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n  >;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        PhaseRouterService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByIssueAndRepo: jest.fn(),\n            createTask: jest.fn(),\n            save: jest.fn(),\n            getPhaseStatus: jest.fn(),\n            setPhaseStatus: jest.fn(),\n            setGate: jest.fn(),\n            clearGate: jest.fn(),\n            resetForRetry: jest.fn(),\n            resetForRestart: jest.fn(),\n            setQualityGateOverride: jest.fn(),\n            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: InternalAdapterService,\n          useValue: {\n            handleSignal: jest.fn(),\n            getNextPhase: jest.fn(),\n          },\n        },\n        {\n          provide: ClaudeInvocationService,\n          useValue: {\n            invoke: jest.fn(),\n          },\n        },\n        {\n          provide: WorktreeService,\n          useValue: {\n            getOrCreateWorktree: jest.fn(),\n            cleanupWorktree: jest.fn(),\n          },\n        },\n        {\n          provide: ArtefactsService,\n          useValue: {\n            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n            buildCentralTaskDir: jest\n              .fn()\n              .mockReturnValue(\n                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n              ),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n            onPhaseComplete: jest.fn(),\n            onPhaseSkip: jest.fn(),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn(),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: GitHubService,\n          useValue: {\n            postComment: jest.fn().mockResolvedValue(undefined),\n            getPRFiles: jest.fn().mockResolvedValue([]),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: TokenUsageService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get(PhaseRouterService);\n    taskState = module.get(TaskStateService);\n    internalAdapter = module.get(InternalAdapterService);\n    claude = module.get(ClaudeInvocationService);\n    worktreeService = module.get(WorktreeService);\n    artefacts = module.get(ArtefactsService);\n    hooks = module.get(PhaseHooksService);\n    eventService = module.get(EventService);\n    gitHub = module.get(GitHubService);\n    eventBus = module.get(EventBusService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  describe('route()', () => {\n    it('should create a new task when none exists and execute the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({\n          ISSUE_NUMBER: '42',\n          REPO: 'org/repo',\n          PHASE: 'refine',\n        }),\n        task.id,\n      );\n    });\n\n    it('should use existing task when found', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should use the directive from the event when creating a task', async () => {\n      const task = makeTask({ directive: 'careful' });\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ directive: 'careful' }));\n\n      expect(taskState.createTask).toHaveBeenCalledWith(\n        42,\n        'org/repo',\n        'careful',\n      );\n    });\n\n    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n      const task = makeTask({ directive: 'careful', currentPhase: 'plan' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      // Plan runs — gate is applied AFTER completion in advanceAndEnqueue, not before\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n      expect(taskState.setGate).not.toHaveBeenCalled();\n    });\n\n    it('should not gate before phase execution (gates are post-completion in internal-adapter)', async () => {\n      const task = makeTask({ directive: 'auto' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should not gate any phase with quick directive', async () => {\n      const task = makeTask({ directive: 'quick' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip when event type cannot be resolved to a phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'unknown_event' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip execution when phase is already active (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should execute phase when phase status is pending (first run)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip completed phase on internal phase_* event (dedup guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip skipped phase on internal phase_* event', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'skipped',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('skipped');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip phase execution when task has been paused by /agent stop', async () => {\n      // Simulate: task.status='paused' was set in DB while a phase_plan\n      // completion event was already in the queue.  The fresh DB read must win.\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'paused',\n        phasePlan: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent stop',\n          actor: 'zhfahan77',\n        }),\n      );\n\n      expect(taskState.save).toHaveBeenCalledWith(\n        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_paused',\n        'phase-router',\n        { actor: 'zhfahan77' },\n      );\n    });\n\n    it('should allow re-executing completed refine on issue_comment event', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_cancelled',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle task_cancelled with no existing task gracefully', async () => {\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(taskState.save).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockRejectedValue(\n        new Error('worktree not found'),\n      );\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      // Should not throw — cleanup failure is best-effort\n    });\n\n    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_reopened',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should clear gate and execute phase on directive event', async () => {\n      const task = makeTask({\n        status: 'gated',\n        directive: 'quick',\n        currentPhase: 'review',\n        gatePhase: 'review',\n        gateReason: 'Awaiting approval',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: 'approve' }),\n      );\n\n      expect(task.status).toBe('active');\n      expect(task.gatePhase).toBeNull();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'gate_released',\n        'phase-router',\n        expect.objectContaining({ directive: 'approve' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({ PHASE: 'review' }),\n        task.id,\n      );\n    });\n  });\n\n  describe('executePhase() via route()', () => {\n    it('should create a worktree for the deliver phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/worktree' }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree for the address_review phase', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix-review' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_address_review' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'address_review',\n        expect.objectContaining({\n          WORKTREE_PATH: '/tmp/worktree',\n          PR_NUMBER: '50',\n          PR_BRANCH: 'feat/fix-review',\n        }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree with agents-md prefix for compound phase', async () => {\n      const task = makeTask({ currentPhase: 'compound' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue(\n        '/tmp/compound-worktree',\n      );\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_compound' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'agents-md',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'compound',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/compound-worktree' }),\n        task.id,\n      );\n    });\n\n    it('should pass rcBranch to getOrCreateWorktree when an active release exists', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue({\n        releaseId: 7,\n        key: 'RC-1',\n        title: 'Release 1',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v1.2',\n        baseBranch: 'master',\n      });\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        'rc/v1.2',\n      );\n    });\n\n    it('should pass undefined baseBranch to getOrCreateWorktree when no release is active', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue(null);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n    });\n\n    it('should not create a worktree for non-execute phases', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(worktreeService.getOrCreateWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree creation failure with transient block', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockRejectedValue(\n        new Error('disk full'),\n      );\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(internalAdapter.handleSignal).toHaveBeenCalledWith(\n        task.id,\n        'deliver',\n        'BLOCKED:TRANSIENT',\n        expect.stringContaining('disk full'),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should set phase status to active before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'plan',\n        'active',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('should include PR env vars when task has prNumber and prBranch', async () => {\n      const task = makeTask({\n        prNumber: 99,\n        prBranch: 'feat/test',\n        directive: 'quick',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_review' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({\n          PR_NUMBER: '99',\n          PR_BRANCH: 'feat/test',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include TASK_DIR env var pointing to central path', async () => {\n      const centralPath =\n        '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42';\n      artefacts.buildCentralTaskDir.mockReturnValue(centralPath);\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(artefacts.buildCentralTaskDir).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_DIR: centralPath }),\n        task.id,\n      );\n    });\n\n    it('should call onPhaseStart before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      const callOrder: string[] = [];\n      hooks.onPhaseStart.mockImplementation((): Promise<void> => {\n        callOrder.push('onPhaseStart');\n        return Promise.resolve();\n      });\n      claude.invoke.mockImplementation((): Promise<void> => {\n        callOrder.push('claude');\n        return Promise.resolve();\n      });\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(hooks.onPhaseStart).toHaveBeenCalledWith(\n        expect.objectContaining({ issue: 42, repo: 'org/repo' }),\n        'refine',\n      );\n      expect(callOrder).toEqual(['onPhaseStart', 'claude']);\n    });\n\n    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n\n      await expect(\n        service.route(makeEvent({ type: 'phase_deliver' })),\n      ).rejects.toThrow('Claude crash');\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should not cleanup worktree after normal phase execution', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('resolvePhase()', () => {\n    const task = makeTask();\n\n    it('should resolve issue_assigned to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve intake to refine', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n        'refine',\n      );\n    });\n\n    it('should resolve issue_comment to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n          task,\n        ),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve phase_* events to the phase name', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n      ).toBe('refine');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n      ).toBe('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n      ).toBe('deliver');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n      ).toBe('compound');\n    });\n\n    it('should resolve _completed events to the next phase', () => {\n      internalAdapter.getNextPhase.mockReturnValue('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'refine_completed' }), task),\n      ).toBe('plan');\n      expect(internalAdapter.getNextPhase).toHaveBeenCalledWith('refine');\n    });\n\n    it('should resolve pr_merged to compound', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'pr_merged' }), task)).toBe(\n        'compound',\n      );\n    });\n\n    it('should resolve review_approved to null (handled specially in route)', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'review_approved' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve review_changes_requested to address_review', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'review_changes_requested' }),\n          task,\n        ),\n      ).toBe('address_review');\n    });\n\n    it('should resolve ci_passed to deliver', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'ci_passed' }), task)).toBe(\n        'deliver',\n      );\n    });\n\n    it('should resolve ci_failure to deliver', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'ci_failure' }), task),\n      ).toBe('deliver');\n    });\n\n    it('should resolve gate_released to the gated phase', () => {\n      const gatedTask = makeTask({ gatePhase: 'plan' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'gate_released' }), gatedTask),\n      ).toBe('plan');\n    });\n\n    it('should resolve pr_comment to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'pr_comment' }), task),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve task_cancelled to null', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_cancelled' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve task_reopened to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_reopened' }), task),\n      ).toBe('refine');\n    });\n\n    it('should return null for unknown events', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'something_unknown' }), task),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already blocked', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'refine',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), blockedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already gated', () => {\n      const gatedTask = makeTask({\n        status: 'gated',\n        currentPhase: 'review',\n        gatePhase: 'review',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), gatedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already past refine', () => {\n      const planTask = makeTask({ currentPhase: 'plan', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), planTask),\n      ).toBeNull();\n    });\n\n    it('should allow intake when task is still at refine', () => {\n      const refineTask = makeTask({ currentPhase: 'refine', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n      ).toBe('refine');\n    });\n\n    it('should resolve directive to gated phase for gated tasks', () => {\n      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n      ).toBe('review');\n    });\n\n    it('should return null for directive on non-gated tasks', () => {\n      const activeTask = makeTask({ status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n      ).toBeNull();\n    });\n\n    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('deliver');\n    });\n\n    it('should resolve retry directive from event.directive field', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'retry' }),\n          stuckTask,\n        ),\n      ).toBe('plan');\n    });\n\n    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'deliver',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n          blockedTask,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve restart directive from event.directive field', () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'restart' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'execute' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via event.directive field', () => {\n      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should return null for retry on task with no currentPhase', () => {\n      const taskNoPhase = makeTask({\n        status: 'active',\n        currentPhase: null as unknown as string,\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          taskNoPhase,\n        ),\n      ).toBeNull();\n    });\n  });\n\n  // isGated() removed — gate logic moved to advanceAndEnqueue() in internal-adapter.\n  // Gate tests are now in internal-adapter.service.spec.ts.\n\n  // signal mapping tests removed — result handling moved to InvocationResultListener.\n\n  describe('issue_comment routing', () => {\n    it('should route issue_comment events to the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should route issue_comment with prNumber to reply_to_pr_comment with COMMENT_ID in env', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PHASE: 'reply_to_pr_comment',\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include PR_NUMBER from event when task has no prNumber', async () => {\n      const task = makeTask({ prNumber: null });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should not include AGENT_PERSONA in env for any phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('AGENT_PERSONA');\n    });\n  });\n\n  describe('review phase status on external review events', () => {\n    it('marks review phase complete when review_approved routes to deliver', async () => {\n      // Use quick directive so deliver is not gated and Claude gets invoked\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('marks review phase complete when review_changes_requested routes to address_review', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // address_review phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'review_changes_requested' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n\n    it('skips review phase status update when review is already complete', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('complete') // review status — already complete\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      // setPhaseStatus should NOT be called for review since it's already complete\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n  });\n\n  describe('event recording', () => {\n    it('should record phase_started event', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'phase_started',\n        'phase-router',\n        { phase: 'refine' },\n      );\n    });\n\n    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n    // after the phase completes, not by phase-router before execution.\n  });\n\n  describe('retry/restart directive handling', () => {\n    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'execute',\n        expect.objectContaining({ PHASE: 'execute' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {\n      const task = makeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n    });\n\n    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent retry',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Retry rejected'),\n      );\n    });\n\n    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent restart',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Restart rejected'),\n      );\n    });\n\n    it('unknown directive: posts feedback comment listing valid directives', async () => {\n      const task = makeTask({ status: 'active' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent foobar',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('retry'),\n      );\n    });\n  });\n\n  describe('quality gate override', () => {\n    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n        task.id,\n      );\n    });\n\n    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n    });\n\n    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        false,\n      );\n    });\n  });\n\n  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n\n  describe('idempotency guards', () => {\n    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'blocked',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'Awaiting approval',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        gatePhase: null,\n        gateReason: null,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent approve',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();\n    });\n\n    // artefact sync tests removed — artefact handling moved to InvocationResultListener.\n\n    it('should ignore intake event when task is already complete (late duplicate guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'compound',\n        status: 'complete',\n        phaseCompound: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    it('calls createTask once and invoke once when route() is called twice with same event', async () => {\n      const task = makeTask();\n      // First call: no task exists → create\n      // Second call: task now exists → reuse\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValue(task);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      const event = makeEvent({ type: 'issue_assigned' });\n      await service.route(event);\n      await service.route(event);\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Second call: phase is still 'pending' so invoke runs again (not prevented by active guard),\n      // but the idempotency contract for external events allows this. The important guarantee is\n      // createTask is not called twice.\n      expect(claude.invoke).toHaveBeenCalledTimes(2);\n    });\n\n    it('falls back to existing task when createTask throws unique-constraint error', async () => {\n      const existingTask = makeTask({ id: 5 });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null) // first call: no task\n        .mockResolvedValue(existingTask); // fallback + subsequent calls (paused check etc.)\n      taskState.createTask.mockRejectedValueOnce(\n        Object.assign(new Error('UNIQUE constraint failed'), {\n          code: 'SQLITE_CONSTRAINT',\n        }),\n      );\n      taskState.save.mockResolvedValue(existingTask);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_assigned' }));\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Falls back to the existing task and continues execution\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_ID: String(existingTask.id) }),\n        existingTask.id,\n      );\n    });\n\n    it('re-throws createTask error when no existing task can be found', async () => {\n      const error = Object.assign(new Error('DB error'), {\n        code: 'SOME_OTHER_ERROR',\n      });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValueOnce(null); // fallback also finds nothing\n      taskState.createTask.mockRejectedValueOnce(error);\n\n      await expect(\n        service.route(makeEvent({ type: 'issue_assigned' })),\n      ).rejects.toThrow('DB error');\n    });\n\n    it('skips phase_ event re-execution when phase is failed and task is gated (BLOCKED:PERSISTENT retry)', async () => {\n      const task = makeTask({\n        status: 'gated',\n        gatePhase: 'deliver',\n        gateReason: 'Max retries exceeded',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n\n      // BullMQ stall-retry re-delivers the phase_deliver job\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('allows phase_ re-execution when phase is failed but task is NOT gated (transient retry)', async () => {\n      // InternalAdapterService sets phase to 'failed' and re-enqueues on transient block;\n      // task.status stays 'active'. This legitimate retry must proceed.\n      const task = makeTask({\n        status: 'active',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n      // Phase status is 'active' in-memory on the task passed into executePhase.\n      // This simulates a scenario where route()'s active-phase check was bypassed.\n      const task = makeTask({\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('calls invoke exactly once when route() processes the same event while phase is active', async () => {\n      const task = makeTask({ phaseRefine: 'pending' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // First getPhaseStatus call (route guard): 'pending' → proceed\n      // executePhase() defensive guard also returns 'pending' → proceed to invoke\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      hooks.onPhaseComplete.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n","structuredPatch":[{"oldStart":981,"oldLines":7,"newStart":981,"newLines":7,"lines":["     });"," ","     it('should resolve replan directive to refine via commentBody', () => {","-      const task = makeTask({ status: 'blocked', currentPhase: 'execute' });","+      const task = makeTask({ status: 'blocked', currentPhase: 'deliver' });","       expect(","         service.resolvePhase(","           makeEvent({ type: 'directive', commentBody: '/agent replan' }),"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01SQBxqaxsZ2AmJpSKAMGLHN","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WRzxQHLyv4N4rKKYTSBzcG","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","old_string":"    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });","new_string":"    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n        phaseDeliver: 'active',\n      });"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":293,"cache_read_input_tokens":100380,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":293},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"63588598-7163-433c-98fa-079c990a3780"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WRzxQHLyv4N4rKKYTSBzcG","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"27752a5c-15f4-4b1f-a85a-ace7b7724ade","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","oldString":"    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });","newString":"    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n        phaseDeliver: 'active',\n      });","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { PhaseRouterService } from './phase-router.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\nimport { ClaudeInvocationService } from '../invoke/claude-invocation.service';\nimport { WorktreeService } from '../worktree/worktree.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventService } from '../event/event.service';\nimport { GitHubService } from '../github/github.service';\nimport { TokenUsageService } from '../token-usage/token-usage.service';\nimport { ReleaseService } from '../release/release.service';\nimport { EventBusService } from '../event-bus';\nimport type { TaskEntity } from '../database/entities/task.entity';\nimport type { DispatchEvent } from '../queue/job-queue.interface';\n\nfunction makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n  return {\n    id: 1,\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    currentPhase: 'refine',\n    status: 'active',\n    gatePhase: null,\n    gateReason: null,\n    prNumber: null,\n    prBranch: null,\n    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseReview: 'pending',\n    phaseDeliver: 'pending',\n    phaseCompound: 'pending',\n    qualityGateOverride: false,\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    ...overrides,\n  } as TaskEntity;\n}\n\nfunction makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n  return {\n    type: 'issue_assigned',\n    issueNumber: 42,\n    repo: 'org/repo',\n    ...overrides,\n  };\n}\n\ndescribe('PhaseRouterService', () => {\n  let service: PhaseRouterService;\n  let taskState: jest.Mocked<TaskStateService>;\n  let internalAdapter: jest.Mocked<InternalAdapterService>;\n  let claude: jest.Mocked<ClaudeInvocationService>;\n  let worktreeService: jest.Mocked<WorktreeService>;\n  let artefacts: jest.Mocked<ArtefactsService>;\n  let hooks: jest.Mocked<PhaseHooksService>;\n  let eventService: jest.Mocked<EventService>;\n  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let releaseService: jest.Mocked<\n    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n  >;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        PhaseRouterService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByIssueAndRepo: jest.fn(),\n            createTask: jest.fn(),\n            save: jest.fn(),\n            getPhaseStatus: jest.fn(),\n            setPhaseStatus: jest.fn(),\n            setGate: jest.fn(),\n            clearGate: jest.fn(),\n            resetForRetry: jest.fn(),\n            resetForRestart: jest.fn(),\n            setQualityGateOverride: jest.fn(),\n            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: InternalAdapterService,\n          useValue: {\n            handleSignal: jest.fn(),\n            getNextPhase: jest.fn(),\n          },\n        },\n        {\n          provide: ClaudeInvocationService,\n          useValue: {\n            invoke: jest.fn(),\n          },\n        },\n        {\n          provide: WorktreeService,\n          useValue: {\n            getOrCreateWorktree: jest.fn(),\n            cleanupWorktree: jest.fn(),\n          },\n        },\n        {\n          provide: ArtefactsService,\n          useValue: {\n            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n            buildCentralTaskDir: jest\n              .fn()\n              .mockReturnValue(\n                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n              ),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n            onPhaseComplete: jest.fn(),\n            onPhaseSkip: jest.fn(),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn(),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: GitHubService,\n          useValue: {\n            postComment: jest.fn().mockResolvedValue(undefined),\n            getPRFiles: jest.fn().mockResolvedValue([]),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: TokenUsageService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get(PhaseRouterService);\n    taskState = module.get(TaskStateService);\n    internalAdapter = module.get(InternalAdapterService);\n    claude = module.get(ClaudeInvocationService);\n    worktreeService = module.get(WorktreeService);\n    artefacts = module.get(ArtefactsService);\n    hooks = module.get(PhaseHooksService);\n    eventService = module.get(EventService);\n    gitHub = module.get(GitHubService);\n    eventBus = module.get(EventBusService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  describe('route()', () => {\n    it('should create a new task when none exists and execute the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({\n          ISSUE_NUMBER: '42',\n          REPO: 'org/repo',\n          PHASE: 'refine',\n        }),\n        task.id,\n      );\n    });\n\n    it('should use existing task when found', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should use the directive from the event when creating a task', async () => {\n      const task = makeTask({ directive: 'careful' });\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ directive: 'careful' }));\n\n      expect(taskState.createTask).toHaveBeenCalledWith(\n        42,\n        'org/repo',\n        'careful',\n      );\n    });\n\n    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n      const task = makeTask({ directive: 'careful', currentPhase: 'plan' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      // Plan runs — gate is applied AFTER completion in advanceAndEnqueue, not before\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n      expect(taskState.setGate).not.toHaveBeenCalled();\n    });\n\n    it('should not gate before phase execution (gates are post-completion in internal-adapter)', async () => {\n      const task = makeTask({ directive: 'auto' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should not gate any phase with quick directive', async () => {\n      const task = makeTask({ directive: 'quick' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip when event type cannot be resolved to a phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'unknown_event' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip execution when phase is already active (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should execute phase when phase status is pending (first run)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip completed phase on internal phase_* event (dedup guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip skipped phase on internal phase_* event', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'skipped',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('skipped');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip phase execution when task has been paused by /agent stop', async () => {\n      // Simulate: task.status='paused' was set in DB while a phase_plan\n      // completion event was already in the queue.  The fresh DB read must win.\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'paused',\n        phasePlan: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent stop',\n          actor: 'zhfahan77',\n        }),\n      );\n\n      expect(taskState.save).toHaveBeenCalledWith(\n        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_paused',\n        'phase-router',\n        { actor: 'zhfahan77' },\n      );\n    });\n\n    it('should allow re-executing completed refine on issue_comment event', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_cancelled',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle task_cancelled with no existing task gracefully', async () => {\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(taskState.save).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockRejectedValue(\n        new Error('worktree not found'),\n      );\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      // Should not throw — cleanup failure is best-effort\n    });\n\n    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_reopened',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should clear gate and execute phase on directive event', async () => {\n      const task = makeTask({\n        status: 'gated',\n        directive: 'quick',\n        currentPhase: 'review',\n        gatePhase: 'review',\n        gateReason: 'Awaiting approval',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: 'approve' }),\n      );\n\n      expect(task.status).toBe('active');\n      expect(task.gatePhase).toBeNull();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'gate_released',\n        'phase-router',\n        expect.objectContaining({ directive: 'approve' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({ PHASE: 'review' }),\n        task.id,\n      );\n    });\n  });\n\n  describe('executePhase() via route()', () => {\n    it('should create a worktree for the deliver phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/worktree' }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree for the address_review phase', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix-review' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_address_review' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'address_review',\n        expect.objectContaining({\n          WORKTREE_PATH: '/tmp/worktree',\n          PR_NUMBER: '50',\n          PR_BRANCH: 'feat/fix-review',\n        }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree with agents-md prefix for compound phase', async () => {\n      const task = makeTask({ currentPhase: 'compound' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue(\n        '/tmp/compound-worktree',\n      );\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_compound' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'agents-md',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'compound',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/compound-worktree' }),\n        task.id,\n      );\n    });\n\n    it('should pass rcBranch to getOrCreateWorktree when an active release exists', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue({\n        releaseId: 7,\n        key: 'RC-1',\n        title: 'Release 1',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v1.2',\n        baseBranch: 'master',\n      });\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        'rc/v1.2',\n      );\n    });\n\n    it('should pass undefined baseBranch to getOrCreateWorktree when no release is active', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue(null);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n    });\n\n    it('should not create a worktree for non-execute phases', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(worktreeService.getOrCreateWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree creation failure with transient block', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockRejectedValue(\n        new Error('disk full'),\n      );\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(internalAdapter.handleSignal).toHaveBeenCalledWith(\n        task.id,\n        'deliver',\n        'BLOCKED:TRANSIENT',\n        expect.stringContaining('disk full'),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should set phase status to active before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'plan',\n        'active',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('should include PR env vars when task has prNumber and prBranch', async () => {\n      const task = makeTask({\n        prNumber: 99,\n        prBranch: 'feat/test',\n        directive: 'quick',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_review' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({\n          PR_NUMBER: '99',\n          PR_BRANCH: 'feat/test',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include TASK_DIR env var pointing to central path', async () => {\n      const centralPath =\n        '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42';\n      artefacts.buildCentralTaskDir.mockReturnValue(centralPath);\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(artefacts.buildCentralTaskDir).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_DIR: centralPath }),\n        task.id,\n      );\n    });\n\n    it('should call onPhaseStart before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      const callOrder: string[] = [];\n      hooks.onPhaseStart.mockImplementation((): Promise<void> => {\n        callOrder.push('onPhaseStart');\n        return Promise.resolve();\n      });\n      claude.invoke.mockImplementation((): Promise<void> => {\n        callOrder.push('claude');\n        return Promise.resolve();\n      });\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(hooks.onPhaseStart).toHaveBeenCalledWith(\n        expect.objectContaining({ issue: 42, repo: 'org/repo' }),\n        'refine',\n      );\n      expect(callOrder).toEqual(['onPhaseStart', 'claude']);\n    });\n\n    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n\n      await expect(\n        service.route(makeEvent({ type: 'phase_deliver' })),\n      ).rejects.toThrow('Claude crash');\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should not cleanup worktree after normal phase execution', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('resolvePhase()', () => {\n    const task = makeTask();\n\n    it('should resolve issue_assigned to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve intake to refine', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n        'refine',\n      );\n    });\n\n    it('should resolve issue_comment to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n          task,\n        ),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve phase_* events to the phase name', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n      ).toBe('refine');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n      ).toBe('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n      ).toBe('deliver');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n      ).toBe('compound');\n    });\n\n    it('should resolve _completed events to the next phase', () => {\n      internalAdapter.getNextPhase.mockReturnValue('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'refine_completed' }), task),\n      ).toBe('plan');\n      expect(internalAdapter.getNextPhase).toHaveBeenCalledWith('refine');\n    });\n\n    it('should resolve pr_merged to compound', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'pr_merged' }), task)).toBe(\n        'compound',\n      );\n    });\n\n    it('should resolve review_approved to null (handled specially in route)', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'review_approved' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve review_changes_requested to address_review', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'review_changes_requested' }),\n          task,\n        ),\n      ).toBe('address_review');\n    });\n\n    it('should resolve ci_passed to deliver', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'ci_passed' }), task)).toBe(\n        'deliver',\n      );\n    });\n\n    it('should resolve ci_failure to deliver', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'ci_failure' }), task),\n      ).toBe('deliver');\n    });\n\n    it('should resolve gate_released to the gated phase', () => {\n      const gatedTask = makeTask({ gatePhase: 'plan' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'gate_released' }), gatedTask),\n      ).toBe('plan');\n    });\n\n    it('should resolve pr_comment to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'pr_comment' }), task),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve task_cancelled to null', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_cancelled' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve task_reopened to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_reopened' }), task),\n      ).toBe('refine');\n    });\n\n    it('should return null for unknown events', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'something_unknown' }), task),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already blocked', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'refine',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), blockedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already gated', () => {\n      const gatedTask = makeTask({\n        status: 'gated',\n        currentPhase: 'review',\n        gatePhase: 'review',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), gatedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already past refine', () => {\n      const planTask = makeTask({ currentPhase: 'plan', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), planTask),\n      ).toBeNull();\n    });\n\n    it('should allow intake when task is still at refine', () => {\n      const refineTask = makeTask({ currentPhase: 'refine', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n      ).toBe('refine');\n    });\n\n    it('should resolve directive to gated phase for gated tasks', () => {\n      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n      ).toBe('review');\n    });\n\n    it('should return null for directive on non-gated tasks', () => {\n      const activeTask = makeTask({ status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n      ).toBeNull();\n    });\n\n    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('deliver');\n    });\n\n    it('should resolve retry directive from event.directive field', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'retry' }),\n          stuckTask,\n        ),\n      ).toBe('plan');\n    });\n\n    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'deliver',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n          blockedTask,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve restart directive from event.directive field', () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'restart' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via event.directive field', () => {\n      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should return null for retry on task with no currentPhase', () => {\n      const taskNoPhase = makeTask({\n        status: 'active',\n        currentPhase: null as unknown as string,\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          taskNoPhase,\n        ),\n      ).toBeNull();\n    });\n  });\n\n  // isGated() removed — gate logic moved to advanceAndEnqueue() in internal-adapter.\n  // Gate tests are now in internal-adapter.service.spec.ts.\n\n  // signal mapping tests removed — result handling moved to InvocationResultListener.\n\n  describe('issue_comment routing', () => {\n    it('should route issue_comment events to the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should route issue_comment with prNumber to reply_to_pr_comment with COMMENT_ID in env', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PHASE: 'reply_to_pr_comment',\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include PR_NUMBER from event when task has no prNumber', async () => {\n      const task = makeTask({ prNumber: null });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should not include AGENT_PERSONA in env for any phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('AGENT_PERSONA');\n    });\n  });\n\n  describe('review phase status on external review events', () => {\n    it('marks review phase complete when review_approved routes to deliver', async () => {\n      // Use quick directive so deliver is not gated and Claude gets invoked\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('marks review phase complete when review_changes_requested routes to address_review', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // address_review phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'review_changes_requested' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n\n    it('skips review phase status update when review is already complete', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('complete') // review status — already complete\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      // setPhaseStatus should NOT be called for review since it's already complete\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n  });\n\n  describe('event recording', () => {\n    it('should record phase_started event', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'phase_started',\n        'phase-router',\n        { phase: 'refine' },\n      );\n    });\n\n    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n    // after the phase completes, not by phase-router before execution.\n  });\n\n  describe('retry/restart directive handling', () => {\n    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'execute',\n        expect.objectContaining({ PHASE: 'execute' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {\n      const task = makeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n    });\n\n    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent retry',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Retry rejected'),\n      );\n    });\n\n    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent restart',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Restart rejected'),\n      );\n    });\n\n    it('unknown directive: posts feedback comment listing valid directives', async () => {\n      const task = makeTask({ status: 'active' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent foobar',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('retry'),\n      );\n    });\n  });\n\n  describe('quality gate override', () => {\n    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n        task.id,\n      );\n    });\n\n    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n    });\n\n    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        false,\n      );\n    });\n  });\n\n  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n\n  describe('idempotency guards', () => {\n    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'blocked',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'Awaiting approval',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        gatePhase: null,\n        gateReason: null,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent approve',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();\n    });\n\n    // artefact sync tests removed — artefact handling moved to InvocationResultListener.\n\n    it('should ignore intake event when task is already complete (late duplicate guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'compound',\n        status: 'complete',\n        phaseCompound: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    it('calls createTask once and invoke once when route() is called twice with same event', async () => {\n      const task = makeTask();\n      // First call: no task exists → create\n      // Second call: task now exists → reuse\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValue(task);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      const event = makeEvent({ type: 'issue_assigned' });\n      await service.route(event);\n      await service.route(event);\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Second call: phase is still 'pending' so invoke runs again (not prevented by active guard),\n      // but the idempotency contract for external events allows this. The important guarantee is\n      // createTask is not called twice.\n      expect(claude.invoke).toHaveBeenCalledTimes(2);\n    });\n\n    it('falls back to existing task when createTask throws unique-constraint error', async () => {\n      const existingTask = makeTask({ id: 5 });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null) // first call: no task\n        .mockResolvedValue(existingTask); // fallback + subsequent calls (paused check etc.)\n      taskState.createTask.mockRejectedValueOnce(\n        Object.assign(new Error('UNIQUE constraint failed'), {\n          code: 'SQLITE_CONSTRAINT',\n        }),\n      );\n      taskState.save.mockResolvedValue(existingTask);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_assigned' }));\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Falls back to the existing task and continues execution\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_ID: String(existingTask.id) }),\n        existingTask.id,\n      );\n    });\n\n    it('re-throws createTask error when no existing task can be found', async () => {\n      const error = Object.assign(new Error('DB error'), {\n        code: 'SOME_OTHER_ERROR',\n      });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValueOnce(null); // fallback also finds nothing\n      taskState.createTask.mockRejectedValueOnce(error);\n\n      await expect(\n        service.route(makeEvent({ type: 'issue_assigned' })),\n      ).rejects.toThrow('DB error');\n    });\n\n    it('skips phase_ event re-execution when phase is failed and task is gated (BLOCKED:PERSISTENT retry)', async () => {\n      const task = makeTask({\n        status: 'gated',\n        gatePhase: 'deliver',\n        gateReason: 'Max retries exceeded',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n\n      // BullMQ stall-retry re-delivers the phase_deliver job\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('allows phase_ re-execution when phase is failed but task is NOT gated (transient retry)', async () => {\n      // InternalAdapterService sets phase to 'failed' and re-enqueues on transient block;\n      // task.status stays 'active'. This legitimate retry must proceed.\n      const task = makeTask({\n        status: 'active',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n      // Phase status is 'active' in-memory on the task passed into executePhase.\n      // This simulates a scenario where route()'s active-phase check was bypassed.\n      const task = makeTask({\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('calls invoke exactly once when route() processes the same event while phase is active', async () => {\n      const task = makeTask({ phaseRefine: 'pending' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // First getPhaseStatus call (route guard): 'pending' → proceed\n      // executePhase() defensive guard also returns 'pending' → proceed to invoke\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      hooks.onPhaseComplete.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n","structuredPatch":[{"oldStart":1182,"oldLines":8,"newStart":1182,"newLines":8,"lines":["     it('retry on stuck task: resets state and executes current phase', async () => {","       const task = makeTask({","         status: 'stuck',","-        currentPhase: 'execute',","-        phaseExecute: 'active',","+        currentPhase: 'deliver',","+        phaseDeliver: 'active',","       });","       taskState.findByIssueAndRepo.mockResolvedValue(task);","       taskState.save.mockResolvedValue(task);"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01HFK2kPwdadMPHRLfApRuWv","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014MDrCxUPH3RzSGSd5x7K51","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","old_string":"      expect(claude.invoke).toHaveBeenCalledWith(\n        'execute',\n        expect.objectContaining({ PHASE: 'execute' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {","new_string":"      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ PHASE: 'deliver' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":333,"cache_read_input_tokens":100673,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":333},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"cb872c62-6cab-4b12-890f-4d7bfa713778"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014MDrCxUPH3RzSGSd5x7K51","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"d7725b80-65a9-4d00-9990-527bf8dd65a2","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","oldString":"      expect(claude.invoke).toHaveBeenCalledWith(\n        'execute',\n        expect.objectContaining({ PHASE: 'execute' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {","newString":"      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ PHASE: 'deliver' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { PhaseRouterService } from './phase-router.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\nimport { ClaudeInvocationService } from '../invoke/claude-invocation.service';\nimport { WorktreeService } from '../worktree/worktree.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventService } from '../event/event.service';\nimport { GitHubService } from '../github/github.service';\nimport { TokenUsageService } from '../token-usage/token-usage.service';\nimport { ReleaseService } from '../release/release.service';\nimport { EventBusService } from '../event-bus';\nimport type { TaskEntity } from '../database/entities/task.entity';\nimport type { DispatchEvent } from '../queue/job-queue.interface';\n\nfunction makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n  return {\n    id: 1,\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    currentPhase: 'refine',\n    status: 'active',\n    gatePhase: null,\n    gateReason: null,\n    prNumber: null,\n    prBranch: null,\n    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseReview: 'pending',\n    phaseDeliver: 'pending',\n    phaseCompound: 'pending',\n    qualityGateOverride: false,\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    ...overrides,\n  } as TaskEntity;\n}\n\nfunction makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n  return {\n    type: 'issue_assigned',\n    issueNumber: 42,\n    repo: 'org/repo',\n    ...overrides,\n  };\n}\n\ndescribe('PhaseRouterService', () => {\n  let service: PhaseRouterService;\n  let taskState: jest.Mocked<TaskStateService>;\n  let internalAdapter: jest.Mocked<InternalAdapterService>;\n  let claude: jest.Mocked<ClaudeInvocationService>;\n  let worktreeService: jest.Mocked<WorktreeService>;\n  let artefacts: jest.Mocked<ArtefactsService>;\n  let hooks: jest.Mocked<PhaseHooksService>;\n  let eventService: jest.Mocked<EventService>;\n  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let releaseService: jest.Mocked<\n    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n  >;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        PhaseRouterService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByIssueAndRepo: jest.fn(),\n            createTask: jest.fn(),\n            save: jest.fn(),\n            getPhaseStatus: jest.fn(),\n            setPhaseStatus: jest.fn(),\n            setGate: jest.fn(),\n            clearGate: jest.fn(),\n            resetForRetry: jest.fn(),\n            resetForRestart: jest.fn(),\n            setQualityGateOverride: jest.fn(),\n            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: InternalAdapterService,\n          useValue: {\n            handleSignal: jest.fn(),\n            getNextPhase: jest.fn(),\n          },\n        },\n        {\n          provide: ClaudeInvocationService,\n          useValue: {\n            invoke: jest.fn(),\n          },\n        },\n        {\n          provide: WorktreeService,\n          useValue: {\n            getOrCreateWorktree: jest.fn(),\n            cleanupWorktree: jest.fn(),\n          },\n        },\n        {\n          provide: ArtefactsService,\n          useValue: {\n            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n            buildCentralTaskDir: jest\n              .fn()\n              .mockReturnValue(\n                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n              ),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n            onPhaseComplete: jest.fn(),\n            onPhaseSkip: jest.fn(),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn(),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: GitHubService,\n          useValue: {\n            postComment: jest.fn().mockResolvedValue(undefined),\n            getPRFiles: jest.fn().mockResolvedValue([]),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: TokenUsageService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get(PhaseRouterService);\n    taskState = module.get(TaskStateService);\n    internalAdapter = module.get(InternalAdapterService);\n    claude = module.get(ClaudeInvocationService);\n    worktreeService = module.get(WorktreeService);\n    artefacts = module.get(ArtefactsService);\n    hooks = module.get(PhaseHooksService);\n    eventService = module.get(EventService);\n    gitHub = module.get(GitHubService);\n    eventBus = module.get(EventBusService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  describe('route()', () => {\n    it('should create a new task when none exists and execute the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({\n          ISSUE_NUMBER: '42',\n          REPO: 'org/repo',\n          PHASE: 'refine',\n        }),\n        task.id,\n      );\n    });\n\n    it('should use existing task when found', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should use the directive from the event when creating a task', async () => {\n      const task = makeTask({ directive: 'careful' });\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ directive: 'careful' }));\n\n      expect(taskState.createTask).toHaveBeenCalledWith(\n        42,\n        'org/repo',\n        'careful',\n      );\n    });\n\n    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n      const task = makeTask({ directive: 'careful', currentPhase: 'plan' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      // Plan runs — gate is applied AFTER completion in advanceAndEnqueue, not before\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n      expect(taskState.setGate).not.toHaveBeenCalled();\n    });\n\n    it('should not gate before phase execution (gates are post-completion in internal-adapter)', async () => {\n      const task = makeTask({ directive: 'auto' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should not gate any phase with quick directive', async () => {\n      const task = makeTask({ directive: 'quick' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip when event type cannot be resolved to a phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'unknown_event' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip execution when phase is already active (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should execute phase when phase status is pending (first run)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip completed phase on internal phase_* event (dedup guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip skipped phase on internal phase_* event', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'skipped',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('skipped');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip phase execution when task has been paused by /agent stop', async () => {\n      // Simulate: task.status='paused' was set in DB while a phase_plan\n      // completion event was already in the queue.  The fresh DB read must win.\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'paused',\n        phasePlan: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent stop',\n          actor: 'zhfahan77',\n        }),\n      );\n\n      expect(taskState.save).toHaveBeenCalledWith(\n        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_paused',\n        'phase-router',\n        { actor: 'zhfahan77' },\n      );\n    });\n\n    it('should allow re-executing completed refine on issue_comment event', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_cancelled',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle task_cancelled with no existing task gracefully', async () => {\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(taskState.save).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockRejectedValue(\n        new Error('worktree not found'),\n      );\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      // Should not throw — cleanup failure is best-effort\n    });\n\n    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_reopened',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should clear gate and execute phase on directive event', async () => {\n      const task = makeTask({\n        status: 'gated',\n        directive: 'quick',\n        currentPhase: 'review',\n        gatePhase: 'review',\n        gateReason: 'Awaiting approval',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: 'approve' }),\n      );\n\n      expect(task.status).toBe('active');\n      expect(task.gatePhase).toBeNull();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'gate_released',\n        'phase-router',\n        expect.objectContaining({ directive: 'approve' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({ PHASE: 'review' }),\n        task.id,\n      );\n    });\n  });\n\n  describe('executePhase() via route()', () => {\n    it('should create a worktree for the deliver phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/worktree' }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree for the address_review phase', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix-review' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_address_review' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'address_review',\n        expect.objectContaining({\n          WORKTREE_PATH: '/tmp/worktree',\n          PR_NUMBER: '50',\n          PR_BRANCH: 'feat/fix-review',\n        }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree with agents-md prefix for compound phase', async () => {\n      const task = makeTask({ currentPhase: 'compound' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue(\n        '/tmp/compound-worktree',\n      );\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_compound' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'agents-md',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'compound',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/compound-worktree' }),\n        task.id,\n      );\n    });\n\n    it('should pass rcBranch to getOrCreateWorktree when an active release exists', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue({\n        releaseId: 7,\n        key: 'RC-1',\n        title: 'Release 1',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v1.2',\n        baseBranch: 'master',\n      });\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        'rc/v1.2',\n      );\n    });\n\n    it('should pass undefined baseBranch to getOrCreateWorktree when no release is active', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue(null);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n    });\n\n    it('should not create a worktree for non-execute phases', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(worktreeService.getOrCreateWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree creation failure with transient block', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockRejectedValue(\n        new Error('disk full'),\n      );\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(internalAdapter.handleSignal).toHaveBeenCalledWith(\n        task.id,\n        'deliver',\n        'BLOCKED:TRANSIENT',\n        expect.stringContaining('disk full'),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should set phase status to active before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'plan',\n        'active',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('should include PR env vars when task has prNumber and prBranch', async () => {\n      const task = makeTask({\n        prNumber: 99,\n        prBranch: 'feat/test',\n        directive: 'quick',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_review' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({\n          PR_NUMBER: '99',\n          PR_BRANCH: 'feat/test',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include TASK_DIR env var pointing to central path', async () => {\n      const centralPath =\n        '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42';\n      artefacts.buildCentralTaskDir.mockReturnValue(centralPath);\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(artefacts.buildCentralTaskDir).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_DIR: centralPath }),\n        task.id,\n      );\n    });\n\n    it('should call onPhaseStart before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      const callOrder: string[] = [];\n      hooks.onPhaseStart.mockImplementation((): Promise<void> => {\n        callOrder.push('onPhaseStart');\n        return Promise.resolve();\n      });\n      claude.invoke.mockImplementation((): Promise<void> => {\n        callOrder.push('claude');\n        return Promise.resolve();\n      });\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(hooks.onPhaseStart).toHaveBeenCalledWith(\n        expect.objectContaining({ issue: 42, repo: 'org/repo' }),\n        'refine',\n      );\n      expect(callOrder).toEqual(['onPhaseStart', 'claude']);\n    });\n\n    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n\n      await expect(\n        service.route(makeEvent({ type: 'phase_deliver' })),\n      ).rejects.toThrow('Claude crash');\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should not cleanup worktree after normal phase execution', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('resolvePhase()', () => {\n    const task = makeTask();\n\n    it('should resolve issue_assigned to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve intake to refine', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n        'refine',\n      );\n    });\n\n    it('should resolve issue_comment to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n          task,\n        ),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve phase_* events to the phase name', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n      ).toBe('refine');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n      ).toBe('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n      ).toBe('deliver');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n      ).toBe('compound');\n    });\n\n    it('should resolve _completed events to the next phase', () => {\n      internalAdapter.getNextPhase.mockReturnValue('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'refine_completed' }), task),\n      ).toBe('plan');\n      expect(internalAdapter.getNextPhase).toHaveBeenCalledWith('refine');\n    });\n\n    it('should resolve pr_merged to compound', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'pr_merged' }), task)).toBe(\n        'compound',\n      );\n    });\n\n    it('should resolve review_approved to null (handled specially in route)', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'review_approved' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve review_changes_requested to address_review', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'review_changes_requested' }),\n          task,\n        ),\n      ).toBe('address_review');\n    });\n\n    it('should resolve ci_passed to deliver', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'ci_passed' }), task)).toBe(\n        'deliver',\n      );\n    });\n\n    it('should resolve ci_failure to deliver', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'ci_failure' }), task),\n      ).toBe('deliver');\n    });\n\n    it('should resolve gate_released to the gated phase', () => {\n      const gatedTask = makeTask({ gatePhase: 'plan' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'gate_released' }), gatedTask),\n      ).toBe('plan');\n    });\n\n    it('should resolve pr_comment to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'pr_comment' }), task),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve task_cancelled to null', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_cancelled' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve task_reopened to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_reopened' }), task),\n      ).toBe('refine');\n    });\n\n    it('should return null for unknown events', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'something_unknown' }), task),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already blocked', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'refine',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), blockedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already gated', () => {\n      const gatedTask = makeTask({\n        status: 'gated',\n        currentPhase: 'review',\n        gatePhase: 'review',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), gatedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already past refine', () => {\n      const planTask = makeTask({ currentPhase: 'plan', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), planTask),\n      ).toBeNull();\n    });\n\n    it('should allow intake when task is still at refine', () => {\n      const refineTask = makeTask({ currentPhase: 'refine', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n      ).toBe('refine');\n    });\n\n    it('should resolve directive to gated phase for gated tasks', () => {\n      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n      ).toBe('review');\n    });\n\n    it('should return null for directive on non-gated tasks', () => {\n      const activeTask = makeTask({ status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n      ).toBeNull();\n    });\n\n    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('deliver');\n    });\n\n    it('should resolve retry directive from event.directive field', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'retry' }),\n          stuckTask,\n        ),\n      ).toBe('plan');\n    });\n\n    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'deliver',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n          blockedTask,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve restart directive from event.directive field', () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'restart' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via event.directive field', () => {\n      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should return null for retry on task with no currentPhase', () => {\n      const taskNoPhase = makeTask({\n        status: 'active',\n        currentPhase: null as unknown as string,\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          taskNoPhase,\n        ),\n      ).toBeNull();\n    });\n  });\n\n  // isGated() removed — gate logic moved to advanceAndEnqueue() in internal-adapter.\n  // Gate tests are now in internal-adapter.service.spec.ts.\n\n  // signal mapping tests removed — result handling moved to InvocationResultListener.\n\n  describe('issue_comment routing', () => {\n    it('should route issue_comment events to the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should route issue_comment with prNumber to reply_to_pr_comment with COMMENT_ID in env', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PHASE: 'reply_to_pr_comment',\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include PR_NUMBER from event when task has no prNumber', async () => {\n      const task = makeTask({ prNumber: null });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should not include AGENT_PERSONA in env for any phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('AGENT_PERSONA');\n    });\n  });\n\n  describe('review phase status on external review events', () => {\n    it('marks review phase complete when review_approved routes to deliver', async () => {\n      // Use quick directive so deliver is not gated and Claude gets invoked\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('marks review phase complete when review_changes_requested routes to address_review', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // address_review phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'review_changes_requested' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n\n    it('skips review phase status update when review is already complete', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('complete') // review status — already complete\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      // setPhaseStatus should NOT be called for review since it's already complete\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n  });\n\n  describe('event recording', () => {\n    it('should record phase_started event', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'phase_started',\n        'phase-router',\n        { phase: 'refine' },\n      );\n    });\n\n    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n    // after the phase completes, not by phase-router before execution.\n  });\n\n  describe('retry/restart directive handling', () => {\n    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n        phaseDeliver: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'execute',\n        expect.objectContaining({ PHASE: 'execute' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {\n      const task = makeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n    });\n\n    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent retry',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Retry rejected'),\n      );\n    });\n\n    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent restart',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Restart rejected'),\n      );\n    });\n\n    it('unknown directive: posts feedback comment listing valid directives', async () => {\n      const task = makeTask({ status: 'active' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent foobar',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('retry'),\n      );\n    });\n  });\n\n  describe('quality gate override', () => {\n    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n        task.id,\n      );\n    });\n\n    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n    });\n\n    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        false,\n      );\n    });\n  });\n\n  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n\n  describe('idempotency guards', () => {\n    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'blocked',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'Awaiting approval',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        gatePhase: null,\n        gateReason: null,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent approve',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();\n    });\n\n    // artefact sync tests removed — artefact handling moved to InvocationResultListener.\n\n    it('should ignore intake event when task is already complete (late duplicate guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'compound',\n        status: 'complete',\n        phaseCompound: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    it('calls createTask once and invoke once when route() is called twice with same event', async () => {\n      const task = makeTask();\n      // First call: no task exists → create\n      // Second call: task now exists → reuse\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValue(task);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      const event = makeEvent({ type: 'issue_assigned' });\n      await service.route(event);\n      await service.route(event);\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Second call: phase is still 'pending' so invoke runs again (not prevented by active guard),\n      // but the idempotency contract for external events allows this. The important guarantee is\n      // createTask is not called twice.\n      expect(claude.invoke).toHaveBeenCalledTimes(2);\n    });\n\n    it('falls back to existing task when createTask throws unique-constraint error', async () => {\n      const existingTask = makeTask({ id: 5 });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null) // first call: no task\n        .mockResolvedValue(existingTask); // fallback + subsequent calls (paused check etc.)\n      taskState.createTask.mockRejectedValueOnce(\n        Object.assign(new Error('UNIQUE constraint failed'), {\n          code: 'SQLITE_CONSTRAINT',\n        }),\n      );\n      taskState.save.mockResolvedValue(existingTask);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_assigned' }));\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Falls back to the existing task and continues execution\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_ID: String(existingTask.id) }),\n        existingTask.id,\n      );\n    });\n\n    it('re-throws createTask error when no existing task can be found', async () => {\n      const error = Object.assign(new Error('DB error'), {\n        code: 'SOME_OTHER_ERROR',\n      });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValueOnce(null); // fallback also finds nothing\n      taskState.createTask.mockRejectedValueOnce(error);\n\n      await expect(\n        service.route(makeEvent({ type: 'issue_assigned' })),\n      ).rejects.toThrow('DB error');\n    });\n\n    it('skips phase_ event re-execution when phase is failed and task is gated (BLOCKED:PERSISTENT retry)', async () => {\n      const task = makeTask({\n        status: 'gated',\n        gatePhase: 'deliver',\n        gateReason: 'Max retries exceeded',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n\n      // BullMQ stall-retry re-delivers the phase_deliver job\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('allows phase_ re-execution when phase is failed but task is NOT gated (transient retry)', async () => {\n      // InternalAdapterService sets phase to 'failed' and re-enqueues on transient block;\n      // task.status stays 'active'. This legitimate retry must proceed.\n      const task = makeTask({\n        status: 'active',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n      // Phase status is 'active' in-memory on the task passed into executePhase.\n      // This simulates a scenario where route()'s active-phase check was bypassed.\n      const task = makeTask({\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('calls invoke exactly once when route() processes the same event while phase is active', async () => {\n      const task = makeTask({ phaseRefine: 'pending' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // First getPhaseStatus call (route guard): 'pending' → proceed\n      // executePhase() defensive guard also returns 'pending' → proceed to invoke\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      hooks.onPhaseComplete.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n","structuredPatch":[{"oldStart":1198,"oldLines":8,"newStart":1198,"newLines":8,"lines":["       expect(taskState.resetForRetry).toHaveBeenCalledWith(task);","       expect(taskState.save).toHaveBeenCalled();","       expect(claude.invoke).toHaveBeenCalledWith(","-        'execute',","-        expect.objectContaining({ PHASE: 'execute' }),","+        'deliver',","+        expect.objectContaining({ PHASE: 'deliver' }),","         task.id,","       );","     });"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01SQ6TJTwm7nyMz8pysEH3id","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UWBhCVtYRuxqW3KSZxVc3D","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","old_string":"    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'execute' });","new_string":"    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":349,"cache_read_input_tokens":101006,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":349},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"cb322095-91d5-4293-aa06-74a0be72b565"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UWBhCVtYRuxqW3KSZxVc3D","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"bd405167-446d-44ef-97e4-49523d6a9ddd","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","oldString":"    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'execute' });","newString":"    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { PhaseRouterService } from './phase-router.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\nimport { ClaudeInvocationService } from '../invoke/claude-invocation.service';\nimport { WorktreeService } from '../worktree/worktree.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventService } from '../event/event.service';\nimport { GitHubService } from '../github/github.service';\nimport { TokenUsageService } from '../token-usage/token-usage.service';\nimport { ReleaseService } from '../release/release.service';\nimport { EventBusService } from '../event-bus';\nimport type { TaskEntity } from '../database/entities/task.entity';\nimport type { DispatchEvent } from '../queue/job-queue.interface';\n\nfunction makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n  return {\n    id: 1,\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    currentPhase: 'refine',\n    status: 'active',\n    gatePhase: null,\n    gateReason: null,\n    prNumber: null,\n    prBranch: null,\n    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseReview: 'pending',\n    phaseDeliver: 'pending',\n    phaseCompound: 'pending',\n    qualityGateOverride: false,\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    ...overrides,\n  } as TaskEntity;\n}\n\nfunction makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n  return {\n    type: 'issue_assigned',\n    issueNumber: 42,\n    repo: 'org/repo',\n    ...overrides,\n  };\n}\n\ndescribe('PhaseRouterService', () => {\n  let service: PhaseRouterService;\n  let taskState: jest.Mocked<TaskStateService>;\n  let internalAdapter: jest.Mocked<InternalAdapterService>;\n  let claude: jest.Mocked<ClaudeInvocationService>;\n  let worktreeService: jest.Mocked<WorktreeService>;\n  let artefacts: jest.Mocked<ArtefactsService>;\n  let hooks: jest.Mocked<PhaseHooksService>;\n  let eventService: jest.Mocked<EventService>;\n  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let releaseService: jest.Mocked<\n    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n  >;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        PhaseRouterService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByIssueAndRepo: jest.fn(),\n            createTask: jest.fn(),\n            save: jest.fn(),\n            getPhaseStatus: jest.fn(),\n            setPhaseStatus: jest.fn(),\n            setGate: jest.fn(),\n            clearGate: jest.fn(),\n            resetForRetry: jest.fn(),\n            resetForRestart: jest.fn(),\n            setQualityGateOverride: jest.fn(),\n            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: InternalAdapterService,\n          useValue: {\n            handleSignal: jest.fn(),\n            getNextPhase: jest.fn(),\n          },\n        },\n        {\n          provide: ClaudeInvocationService,\n          useValue: {\n            invoke: jest.fn(),\n          },\n        },\n        {\n          provide: WorktreeService,\n          useValue: {\n            getOrCreateWorktree: jest.fn(),\n            cleanupWorktree: jest.fn(),\n          },\n        },\n        {\n          provide: ArtefactsService,\n          useValue: {\n            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n            buildCentralTaskDir: jest\n              .fn()\n              .mockReturnValue(\n                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n              ),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n            onPhaseComplete: jest.fn(),\n            onPhaseSkip: jest.fn(),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn(),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: GitHubService,\n          useValue: {\n            postComment: jest.fn().mockResolvedValue(undefined),\n            getPRFiles: jest.fn().mockResolvedValue([]),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: TokenUsageService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get(PhaseRouterService);\n    taskState = module.get(TaskStateService);\n    internalAdapter = module.get(InternalAdapterService);\n    claude = module.get(ClaudeInvocationService);\n    worktreeService = module.get(WorktreeService);\n    artefacts = module.get(ArtefactsService);\n    hooks = module.get(PhaseHooksService);\n    eventService = module.get(EventService);\n    gitHub = module.get(GitHubService);\n    eventBus = module.get(EventBusService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  describe('route()', () => {\n    it('should create a new task when none exists and execute the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({\n          ISSUE_NUMBER: '42',\n          REPO: 'org/repo',\n          PHASE: 'refine',\n        }),\n        task.id,\n      );\n    });\n\n    it('should use existing task when found', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should use the directive from the event when creating a task', async () => {\n      const task = makeTask({ directive: 'careful' });\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ directive: 'careful' }));\n\n      expect(taskState.createTask).toHaveBeenCalledWith(\n        42,\n        'org/repo',\n        'careful',\n      );\n    });\n\n    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n      const task = makeTask({ directive: 'careful', currentPhase: 'plan' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      // Plan runs — gate is applied AFTER completion in advanceAndEnqueue, not before\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n      expect(taskState.setGate).not.toHaveBeenCalled();\n    });\n\n    it('should not gate before phase execution (gates are post-completion in internal-adapter)', async () => {\n      const task = makeTask({ directive: 'auto' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should not gate any phase with quick directive', async () => {\n      const task = makeTask({ directive: 'quick' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip when event type cannot be resolved to a phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'unknown_event' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip execution when phase is already active (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should execute phase when phase status is pending (first run)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip completed phase on internal phase_* event (dedup guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip skipped phase on internal phase_* event', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'skipped',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('skipped');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip phase execution when task has been paused by /agent stop', async () => {\n      // Simulate: task.status='paused' was set in DB while a phase_plan\n      // completion event was already in the queue.  The fresh DB read must win.\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'paused',\n        phasePlan: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent stop',\n          actor: 'zhfahan77',\n        }),\n      );\n\n      expect(taskState.save).toHaveBeenCalledWith(\n        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_paused',\n        'phase-router',\n        { actor: 'zhfahan77' },\n      );\n    });\n\n    it('should allow re-executing completed refine on issue_comment event', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_cancelled',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle task_cancelled with no existing task gracefully', async () => {\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(taskState.save).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockRejectedValue(\n        new Error('worktree not found'),\n      );\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      // Should not throw — cleanup failure is best-effort\n    });\n\n    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_reopened',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should clear gate and execute phase on directive event', async () => {\n      const task = makeTask({\n        status: 'gated',\n        directive: 'quick',\n        currentPhase: 'review',\n        gatePhase: 'review',\n        gateReason: 'Awaiting approval',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: 'approve' }),\n      );\n\n      expect(task.status).toBe('active');\n      expect(task.gatePhase).toBeNull();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'gate_released',\n        'phase-router',\n        expect.objectContaining({ directive: 'approve' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({ PHASE: 'review' }),\n        task.id,\n      );\n    });\n  });\n\n  describe('executePhase() via route()', () => {\n    it('should create a worktree for the deliver phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/worktree' }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree for the address_review phase', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix-review' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_address_review' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'address_review',\n        expect.objectContaining({\n          WORKTREE_PATH: '/tmp/worktree',\n          PR_NUMBER: '50',\n          PR_BRANCH: 'feat/fix-review',\n        }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree with agents-md prefix for compound phase', async () => {\n      const task = makeTask({ currentPhase: 'compound' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue(\n        '/tmp/compound-worktree',\n      );\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_compound' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'agents-md',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'compound',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/compound-worktree' }),\n        task.id,\n      );\n    });\n\n    it('should pass rcBranch to getOrCreateWorktree when an active release exists', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue({\n        releaseId: 7,\n        key: 'RC-1',\n        title: 'Release 1',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v1.2',\n        baseBranch: 'master',\n      });\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        'rc/v1.2',\n      );\n    });\n\n    it('should pass undefined baseBranch to getOrCreateWorktree when no release is active', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue(null);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n    });\n\n    it('should not create a worktree for non-execute phases', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(worktreeService.getOrCreateWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree creation failure with transient block', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockRejectedValue(\n        new Error('disk full'),\n      );\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(internalAdapter.handleSignal).toHaveBeenCalledWith(\n        task.id,\n        'deliver',\n        'BLOCKED:TRANSIENT',\n        expect.stringContaining('disk full'),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should set phase status to active before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'plan',\n        'active',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('should include PR env vars when task has prNumber and prBranch', async () => {\n      const task = makeTask({\n        prNumber: 99,\n        prBranch: 'feat/test',\n        directive: 'quick',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_review' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({\n          PR_NUMBER: '99',\n          PR_BRANCH: 'feat/test',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include TASK_DIR env var pointing to central path', async () => {\n      const centralPath =\n        '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42';\n      artefacts.buildCentralTaskDir.mockReturnValue(centralPath);\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(artefacts.buildCentralTaskDir).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_DIR: centralPath }),\n        task.id,\n      );\n    });\n\n    it('should call onPhaseStart before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      const callOrder: string[] = [];\n      hooks.onPhaseStart.mockImplementation((): Promise<void> => {\n        callOrder.push('onPhaseStart');\n        return Promise.resolve();\n      });\n      claude.invoke.mockImplementation((): Promise<void> => {\n        callOrder.push('claude');\n        return Promise.resolve();\n      });\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(hooks.onPhaseStart).toHaveBeenCalledWith(\n        expect.objectContaining({ issue: 42, repo: 'org/repo' }),\n        'refine',\n      );\n      expect(callOrder).toEqual(['onPhaseStart', 'claude']);\n    });\n\n    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n\n      await expect(\n        service.route(makeEvent({ type: 'phase_deliver' })),\n      ).rejects.toThrow('Claude crash');\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should not cleanup worktree after normal phase execution', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('resolvePhase()', () => {\n    const task = makeTask();\n\n    it('should resolve issue_assigned to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve intake to refine', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n        'refine',\n      );\n    });\n\n    it('should resolve issue_comment to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n          task,\n        ),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve phase_* events to the phase name', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n      ).toBe('refine');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n      ).toBe('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n      ).toBe('deliver');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n      ).toBe('compound');\n    });\n\n    it('should resolve _completed events to the next phase', () => {\n      internalAdapter.getNextPhase.mockReturnValue('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'refine_completed' }), task),\n      ).toBe('plan');\n      expect(internalAdapter.getNextPhase).toHaveBeenCalledWith('refine');\n    });\n\n    it('should resolve pr_merged to compound', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'pr_merged' }), task)).toBe(\n        'compound',\n      );\n    });\n\n    it('should resolve review_approved to null (handled specially in route)', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'review_approved' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve review_changes_requested to address_review', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'review_changes_requested' }),\n          task,\n        ),\n      ).toBe('address_review');\n    });\n\n    it('should resolve ci_passed to deliver', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'ci_passed' }), task)).toBe(\n        'deliver',\n      );\n    });\n\n    it('should resolve ci_failure to deliver', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'ci_failure' }), task),\n      ).toBe('deliver');\n    });\n\n    it('should resolve gate_released to the gated phase', () => {\n      const gatedTask = makeTask({ gatePhase: 'plan' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'gate_released' }), gatedTask),\n      ).toBe('plan');\n    });\n\n    it('should resolve pr_comment to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'pr_comment' }), task),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve task_cancelled to null', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_cancelled' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve task_reopened to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_reopened' }), task),\n      ).toBe('refine');\n    });\n\n    it('should return null for unknown events', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'something_unknown' }), task),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already blocked', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'refine',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), blockedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already gated', () => {\n      const gatedTask = makeTask({\n        status: 'gated',\n        currentPhase: 'review',\n        gatePhase: 'review',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), gatedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already past refine', () => {\n      const planTask = makeTask({ currentPhase: 'plan', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), planTask),\n      ).toBeNull();\n    });\n\n    it('should allow intake when task is still at refine', () => {\n      const refineTask = makeTask({ currentPhase: 'refine', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n      ).toBe('refine');\n    });\n\n    it('should resolve directive to gated phase for gated tasks', () => {\n      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n      ).toBe('review');\n    });\n\n    it('should return null for directive on non-gated tasks', () => {\n      const activeTask = makeTask({ status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n      ).toBeNull();\n    });\n\n    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('deliver');\n    });\n\n    it('should resolve retry directive from event.directive field', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'retry' }),\n          stuckTask,\n        ),\n      ).toBe('plan');\n    });\n\n    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'deliver',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n          blockedTask,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve restart directive from event.directive field', () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'restart' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via event.directive field', () => {\n      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should return null for retry on task with no currentPhase', () => {\n      const taskNoPhase = makeTask({\n        status: 'active',\n        currentPhase: null as unknown as string,\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          taskNoPhase,\n        ),\n      ).toBeNull();\n    });\n  });\n\n  // isGated() removed — gate logic moved to advanceAndEnqueue() in internal-adapter.\n  // Gate tests are now in internal-adapter.service.spec.ts.\n\n  // signal mapping tests removed — result handling moved to InvocationResultListener.\n\n  describe('issue_comment routing', () => {\n    it('should route issue_comment events to the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should route issue_comment with prNumber to reply_to_pr_comment with COMMENT_ID in env', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PHASE: 'reply_to_pr_comment',\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include PR_NUMBER from event when task has no prNumber', async () => {\n      const task = makeTask({ prNumber: null });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should not include AGENT_PERSONA in env for any phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('AGENT_PERSONA');\n    });\n  });\n\n  describe('review phase status on external review events', () => {\n    it('marks review phase complete when review_approved routes to deliver', async () => {\n      // Use quick directive so deliver is not gated and Claude gets invoked\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('marks review phase complete when review_changes_requested routes to address_review', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // address_review phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'review_changes_requested' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n\n    it('skips review phase status update when review is already complete', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('complete') // review status — already complete\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      // setPhaseStatus should NOT be called for review since it's already complete\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n  });\n\n  describe('event recording', () => {\n    it('should record phase_started event', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'phase_started',\n        'phase-router',\n        { phase: 'refine' },\n      );\n    });\n\n    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n    // after the phase completes, not by phase-router before execution.\n  });\n\n  describe('retry/restart directive handling', () => {\n    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n        phaseDeliver: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ PHASE: 'deliver' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {\n      const task = makeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n    });\n\n    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent retry',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Retry rejected'),\n      );\n    });\n\n    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent restart',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Restart rejected'),\n      );\n    });\n\n    it('unknown directive: posts feedback comment listing valid directives', async () => {\n      const task = makeTask({ status: 'active' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent foobar',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('retry'),\n      );\n    });\n  });\n\n  describe('quality gate override', () => {\n    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n        task.id,\n      );\n    });\n\n    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n    });\n\n    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        false,\n      );\n    });\n  });\n\n  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n\n  describe('idempotency guards', () => {\n    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'blocked',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'Awaiting approval',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        gatePhase: null,\n        gateReason: null,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent approve',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();\n    });\n\n    // artefact sync tests removed — artefact handling moved to InvocationResultListener.\n\n    it('should ignore intake event when task is already complete (late duplicate guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'compound',\n        status: 'complete',\n        phaseCompound: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    it('calls createTask once and invoke once when route() is called twice with same event', async () => {\n      const task = makeTask();\n      // First call: no task exists → create\n      // Second call: task now exists → reuse\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValue(task);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      const event = makeEvent({ type: 'issue_assigned' });\n      await service.route(event);\n      await service.route(event);\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Second call: phase is still 'pending' so invoke runs again (not prevented by active guard),\n      // but the idempotency contract for external events allows this. The important guarantee is\n      // createTask is not called twice.\n      expect(claude.invoke).toHaveBeenCalledTimes(2);\n    });\n\n    it('falls back to existing task when createTask throws unique-constraint error', async () => {\n      const existingTask = makeTask({ id: 5 });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null) // first call: no task\n        .mockResolvedValue(existingTask); // fallback + subsequent calls (paused check etc.)\n      taskState.createTask.mockRejectedValueOnce(\n        Object.assign(new Error('UNIQUE constraint failed'), {\n          code: 'SQLITE_CONSTRAINT',\n        }),\n      );\n      taskState.save.mockResolvedValue(existingTask);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_assigned' }));\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Falls back to the existing task and continues execution\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_ID: String(existingTask.id) }),\n        existingTask.id,\n      );\n    });\n\n    it('re-throws createTask error when no existing task can be found', async () => {\n      const error = Object.assign(new Error('DB error'), {\n        code: 'SOME_OTHER_ERROR',\n      });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValueOnce(null); // fallback also finds nothing\n      taskState.createTask.mockRejectedValueOnce(error);\n\n      await expect(\n        service.route(makeEvent({ type: 'issue_assigned' })),\n      ).rejects.toThrow('DB error');\n    });\n\n    it('skips phase_ event re-execution when phase is failed and task is gated (BLOCKED:PERSISTENT retry)', async () => {\n      const task = makeTask({\n        status: 'gated',\n        gatePhase: 'deliver',\n        gateReason: 'Max retries exceeded',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n\n      // BullMQ stall-retry re-delivers the phase_deliver job\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('allows phase_ re-execution when phase is failed but task is NOT gated (transient retry)', async () => {\n      // InternalAdapterService sets phase to 'failed' and re-enqueues on transient block;\n      // task.status stays 'active'. This legitimate retry must proceed.\n      const task = makeTask({\n        status: 'active',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n      // Phase status is 'active' in-memory on the task passed into executePhase.\n      // This simulates a scenario where route()'s active-phase check was bypassed.\n      const task = makeTask({\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('calls invoke exactly once when route() processes the same event while phase is active', async () => {\n      const task = makeTask({ phaseRefine: 'pending' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // First getPhaseStatus call (route guard): 'pending' → proceed\n      // executePhase() defensive guard also returns 'pending' → proceed to invoke\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      hooks.onPhaseComplete.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n","structuredPatch":[{"oldStart":1248,"oldLines":7,"newStart":1248,"newLines":7,"lines":["     });"," ","     it('retry on failed task: rejects with feedback comment', async () => {","-      const task = makeTask({ status: 'failed', currentPhase: 'execute' });","+      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });","       taskState.findByIssueAndRepo.mockResolvedValue(task);"," ","       await service.route("]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01T76wEiWh15k9RXLVkAAmsC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WBAopfHwKaQgZKfTim8Yk4","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","old_string":"    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n      });","new_string":"    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'deliver',\n      });"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":293,"cache_read_input_tokens":101355,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":293},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"19113108-cfae-4448-8277-0dcfe4184e80"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WBAopfHwKaQgZKfTim8Yk4","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"35664953-9c4a-449e-b13e-9131c001ed38","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","oldString":"    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n      });","newString":"    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'deliver',\n      });","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { PhaseRouterService } from './phase-router.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\nimport { ClaudeInvocationService } from '../invoke/claude-invocation.service';\nimport { WorktreeService } from '../worktree/worktree.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventService } from '../event/event.service';\nimport { GitHubService } from '../github/github.service';\nimport { TokenUsageService } from '../token-usage/token-usage.service';\nimport { ReleaseService } from '../release/release.service';\nimport { EventBusService } from '../event-bus';\nimport type { TaskEntity } from '../database/entities/task.entity';\nimport type { DispatchEvent } from '../queue/job-queue.interface';\n\nfunction makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n  return {\n    id: 1,\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    currentPhase: 'refine',\n    status: 'active',\n    gatePhase: null,\n    gateReason: null,\n    prNumber: null,\n    prBranch: null,\n    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseReview: 'pending',\n    phaseDeliver: 'pending',\n    phaseCompound: 'pending',\n    qualityGateOverride: false,\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    ...overrides,\n  } as TaskEntity;\n}\n\nfunction makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n  return {\n    type: 'issue_assigned',\n    issueNumber: 42,\n    repo: 'org/repo',\n    ...overrides,\n  };\n}\n\ndescribe('PhaseRouterService', () => {\n  let service: PhaseRouterService;\n  let taskState: jest.Mocked<TaskStateService>;\n  let internalAdapter: jest.Mocked<InternalAdapterService>;\n  let claude: jest.Mocked<ClaudeInvocationService>;\n  let worktreeService: jest.Mocked<WorktreeService>;\n  let artefacts: jest.Mocked<ArtefactsService>;\n  let hooks: jest.Mocked<PhaseHooksService>;\n  let eventService: jest.Mocked<EventService>;\n  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let releaseService: jest.Mocked<\n    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n  >;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        PhaseRouterService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByIssueAndRepo: jest.fn(),\n            createTask: jest.fn(),\n            save: jest.fn(),\n            getPhaseStatus: jest.fn(),\n            setPhaseStatus: jest.fn(),\n            setGate: jest.fn(),\n            clearGate: jest.fn(),\n            resetForRetry: jest.fn(),\n            resetForRestart: jest.fn(),\n            setQualityGateOverride: jest.fn(),\n            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: InternalAdapterService,\n          useValue: {\n            handleSignal: jest.fn(),\n            getNextPhase: jest.fn(),\n          },\n        },\n        {\n          provide: ClaudeInvocationService,\n          useValue: {\n            invoke: jest.fn(),\n          },\n        },\n        {\n          provide: WorktreeService,\n          useValue: {\n            getOrCreateWorktree: jest.fn(),\n            cleanupWorktree: jest.fn(),\n          },\n        },\n        {\n          provide: ArtefactsService,\n          useValue: {\n            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n            buildCentralTaskDir: jest\n              .fn()\n              .mockReturnValue(\n                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n              ),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n            onPhaseComplete: jest.fn(),\n            onPhaseSkip: jest.fn(),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn(),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: GitHubService,\n          useValue: {\n            postComment: jest.fn().mockResolvedValue(undefined),\n            getPRFiles: jest.fn().mockResolvedValue([]),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: TokenUsageService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get(PhaseRouterService);\n    taskState = module.get(TaskStateService);\n    internalAdapter = module.get(InternalAdapterService);\n    claude = module.get(ClaudeInvocationService);\n    worktreeService = module.get(WorktreeService);\n    artefacts = module.get(ArtefactsService);\n    hooks = module.get(PhaseHooksService);\n    eventService = module.get(EventService);\n    gitHub = module.get(GitHubService);\n    eventBus = module.get(EventBusService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  describe('route()', () => {\n    it('should create a new task when none exists and execute the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({\n          ISSUE_NUMBER: '42',\n          REPO: 'org/repo',\n          PHASE: 'refine',\n        }),\n        task.id,\n      );\n    });\n\n    it('should use existing task when found', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should use the directive from the event when creating a task', async () => {\n      const task = makeTask({ directive: 'careful' });\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ directive: 'careful' }));\n\n      expect(taskState.createTask).toHaveBeenCalledWith(\n        42,\n        'org/repo',\n        'careful',\n      );\n    });\n\n    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n      const task = makeTask({ directive: 'careful', currentPhase: 'plan' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      // Plan runs — gate is applied AFTER completion in advanceAndEnqueue, not before\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n      expect(taskState.setGate).not.toHaveBeenCalled();\n    });\n\n    it('should not gate before phase execution (gates are post-completion in internal-adapter)', async () => {\n      const task = makeTask({ directive: 'auto' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should not gate any phase with quick directive', async () => {\n      const task = makeTask({ directive: 'quick' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip when event type cannot be resolved to a phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'unknown_event' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip execution when phase is already active (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should execute phase when phase status is pending (first run)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip completed phase on internal phase_* event (dedup guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip skipped phase on internal phase_* event', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'skipped',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('skipped');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip phase execution when task has been paused by /agent stop', async () => {\n      // Simulate: task.status='paused' was set in DB while a phase_plan\n      // completion event was already in the queue.  The fresh DB read must win.\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'paused',\n        phasePlan: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent stop',\n          actor: 'zhfahan77',\n        }),\n      );\n\n      expect(taskState.save).toHaveBeenCalledWith(\n        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_paused',\n        'phase-router',\n        { actor: 'zhfahan77' },\n      );\n    });\n\n    it('should allow re-executing completed refine on issue_comment event', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_cancelled',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle task_cancelled with no existing task gracefully', async () => {\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(taskState.save).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockRejectedValue(\n        new Error('worktree not found'),\n      );\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      // Should not throw — cleanup failure is best-effort\n    });\n\n    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_reopened',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should clear gate and execute phase on directive event', async () => {\n      const task = makeTask({\n        status: 'gated',\n        directive: 'quick',\n        currentPhase: 'review',\n        gatePhase: 'review',\n        gateReason: 'Awaiting approval',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: 'approve' }),\n      );\n\n      expect(task.status).toBe('active');\n      expect(task.gatePhase).toBeNull();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'gate_released',\n        'phase-router',\n        expect.objectContaining({ directive: 'approve' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({ PHASE: 'review' }),\n        task.id,\n      );\n    });\n  });\n\n  describe('executePhase() via route()', () => {\n    it('should create a worktree for the deliver phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/worktree' }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree for the address_review phase', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix-review' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_address_review' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'address_review',\n        expect.objectContaining({\n          WORKTREE_PATH: '/tmp/worktree',\n          PR_NUMBER: '50',\n          PR_BRANCH: 'feat/fix-review',\n        }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree with agents-md prefix for compound phase', async () => {\n      const task = makeTask({ currentPhase: 'compound' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue(\n        '/tmp/compound-worktree',\n      );\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_compound' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'agents-md',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'compound',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/compound-worktree' }),\n        task.id,\n      );\n    });\n\n    it('should pass rcBranch to getOrCreateWorktree when an active release exists', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue({\n        releaseId: 7,\n        key: 'RC-1',\n        title: 'Release 1',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v1.2',\n        baseBranch: 'master',\n      });\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        'rc/v1.2',\n      );\n    });\n\n    it('should pass undefined baseBranch to getOrCreateWorktree when no release is active', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue(null);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n    });\n\n    it('should not create a worktree for non-execute phases', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(worktreeService.getOrCreateWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree creation failure with transient block', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockRejectedValue(\n        new Error('disk full'),\n      );\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(internalAdapter.handleSignal).toHaveBeenCalledWith(\n        task.id,\n        'deliver',\n        'BLOCKED:TRANSIENT',\n        expect.stringContaining('disk full'),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should set phase status to active before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'plan',\n        'active',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('should include PR env vars when task has prNumber and prBranch', async () => {\n      const task = makeTask({\n        prNumber: 99,\n        prBranch: 'feat/test',\n        directive: 'quick',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_review' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({\n          PR_NUMBER: '99',\n          PR_BRANCH: 'feat/test',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include TASK_DIR env var pointing to central path', async () => {\n      const centralPath =\n        '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42';\n      artefacts.buildCentralTaskDir.mockReturnValue(centralPath);\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(artefacts.buildCentralTaskDir).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_DIR: centralPath }),\n        task.id,\n      );\n    });\n\n    it('should call onPhaseStart before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      const callOrder: string[] = [];\n      hooks.onPhaseStart.mockImplementation((): Promise<void> => {\n        callOrder.push('onPhaseStart');\n        return Promise.resolve();\n      });\n      claude.invoke.mockImplementation((): Promise<void> => {\n        callOrder.push('claude');\n        return Promise.resolve();\n      });\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(hooks.onPhaseStart).toHaveBeenCalledWith(\n        expect.objectContaining({ issue: 42, repo: 'org/repo' }),\n        'refine',\n      );\n      expect(callOrder).toEqual(['onPhaseStart', 'claude']);\n    });\n\n    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n\n      await expect(\n        service.route(makeEvent({ type: 'phase_deliver' })),\n      ).rejects.toThrow('Claude crash');\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should not cleanup worktree after normal phase execution', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('resolvePhase()', () => {\n    const task = makeTask();\n\n    it('should resolve issue_assigned to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve intake to refine', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n        'refine',\n      );\n    });\n\n    it('should resolve issue_comment to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n          task,\n        ),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve phase_* events to the phase name', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n      ).toBe('refine');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n      ).toBe('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n      ).toBe('deliver');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n      ).toBe('compound');\n    });\n\n    it('should resolve _completed events to the next phase', () => {\n      internalAdapter.getNextPhase.mockReturnValue('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'refine_completed' }), task),\n      ).toBe('plan');\n      expect(internalAdapter.getNextPhase).toHaveBeenCalledWith('refine');\n    });\n\n    it('should resolve pr_merged to compound', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'pr_merged' }), task)).toBe(\n        'compound',\n      );\n    });\n\n    it('should resolve review_approved to null (handled specially in route)', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'review_approved' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve review_changes_requested to address_review', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'review_changes_requested' }),\n          task,\n        ),\n      ).toBe('address_review');\n    });\n\n    it('should resolve ci_passed to deliver', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'ci_passed' }), task)).toBe(\n        'deliver',\n      );\n    });\n\n    it('should resolve ci_failure to deliver', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'ci_failure' }), task),\n      ).toBe('deliver');\n    });\n\n    it('should resolve gate_released to the gated phase', () => {\n      const gatedTask = makeTask({ gatePhase: 'plan' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'gate_released' }), gatedTask),\n      ).toBe('plan');\n    });\n\n    it('should resolve pr_comment to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'pr_comment' }), task),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve task_cancelled to null', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_cancelled' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve task_reopened to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_reopened' }), task),\n      ).toBe('refine');\n    });\n\n    it('should return null for unknown events', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'something_unknown' }), task),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already blocked', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'refine',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), blockedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already gated', () => {\n      const gatedTask = makeTask({\n        status: 'gated',\n        currentPhase: 'review',\n        gatePhase: 'review',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), gatedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already past refine', () => {\n      const planTask = makeTask({ currentPhase: 'plan', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), planTask),\n      ).toBeNull();\n    });\n\n    it('should allow intake when task is still at refine', () => {\n      const refineTask = makeTask({ currentPhase: 'refine', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n      ).toBe('refine');\n    });\n\n    it('should resolve directive to gated phase for gated tasks', () => {\n      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n      ).toBe('review');\n    });\n\n    it('should return null for directive on non-gated tasks', () => {\n      const activeTask = makeTask({ status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n      ).toBeNull();\n    });\n\n    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('deliver');\n    });\n\n    it('should resolve retry directive from event.directive field', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'retry' }),\n          stuckTask,\n        ),\n      ).toBe('plan');\n    });\n\n    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'deliver',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n          blockedTask,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve restart directive from event.directive field', () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'restart' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via event.directive field', () => {\n      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should return null for retry on task with no currentPhase', () => {\n      const taskNoPhase = makeTask({\n        status: 'active',\n        currentPhase: null as unknown as string,\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          taskNoPhase,\n        ),\n      ).toBeNull();\n    });\n  });\n\n  // isGated() removed — gate logic moved to advanceAndEnqueue() in internal-adapter.\n  // Gate tests are now in internal-adapter.service.spec.ts.\n\n  // signal mapping tests removed — result handling moved to InvocationResultListener.\n\n  describe('issue_comment routing', () => {\n    it('should route issue_comment events to the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should route issue_comment with prNumber to reply_to_pr_comment with COMMENT_ID in env', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PHASE: 'reply_to_pr_comment',\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include PR_NUMBER from event when task has no prNumber', async () => {\n      const task = makeTask({ prNumber: null });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should not include AGENT_PERSONA in env for any phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('AGENT_PERSONA');\n    });\n  });\n\n  describe('review phase status on external review events', () => {\n    it('marks review phase complete when review_approved routes to deliver', async () => {\n      // Use quick directive so deliver is not gated and Claude gets invoked\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('marks review phase complete when review_changes_requested routes to address_review', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // address_review phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'review_changes_requested' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n\n    it('skips review phase status update when review is already complete', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('complete') // review status — already complete\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      // setPhaseStatus should NOT be called for review since it's already complete\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n  });\n\n  describe('event recording', () => {\n    it('should record phase_started event', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'phase_started',\n        'phase-router',\n        { phase: 'refine' },\n      );\n    });\n\n    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n    // after the phase completes, not by phase-router before execution.\n  });\n\n  describe('retry/restart directive handling', () => {\n    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n        phaseDeliver: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ PHASE: 'deliver' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {\n      const task = makeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n    });\n\n    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent retry',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Retry rejected'),\n      );\n    });\n\n    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent restart',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Restart rejected'),\n      );\n    });\n\n    it('unknown directive: posts feedback comment listing valid directives', async () => {\n      const task = makeTask({ status: 'active' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent foobar',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('retry'),\n      );\n    });\n  });\n\n  describe('quality gate override', () => {\n    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n        task.id,\n      );\n    });\n\n    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n    });\n\n    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        false,\n      );\n    });\n  });\n\n  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n\n  describe('idempotency guards', () => {\n    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'blocked',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'Awaiting approval',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        gatePhase: null,\n        gateReason: null,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent approve',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();\n    });\n\n    // artefact sync tests removed — artefact handling moved to InvocationResultListener.\n\n    it('should ignore intake event when task is already complete (late duplicate guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'compound',\n        status: 'complete',\n        phaseCompound: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    it('calls createTask once and invoke once when route() is called twice with same event', async () => {\n      const task = makeTask();\n      // First call: no task exists → create\n      // Second call: task now exists → reuse\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValue(task);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      const event = makeEvent({ type: 'issue_assigned' });\n      await service.route(event);\n      await service.route(event);\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Second call: phase is still 'pending' so invoke runs again (not prevented by active guard),\n      // but the idempotency contract for external events allows this. The important guarantee is\n      // createTask is not called twice.\n      expect(claude.invoke).toHaveBeenCalledTimes(2);\n    });\n\n    it('falls back to existing task when createTask throws unique-constraint error', async () => {\n      const existingTask = makeTask({ id: 5 });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null) // first call: no task\n        .mockResolvedValue(existingTask); // fallback + subsequent calls (paused check etc.)\n      taskState.createTask.mockRejectedValueOnce(\n        Object.assign(new Error('UNIQUE constraint failed'), {\n          code: 'SQLITE_CONSTRAINT',\n        }),\n      );\n      taskState.save.mockResolvedValue(existingTask);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_assigned' }));\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Falls back to the existing task and continues execution\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_ID: String(existingTask.id) }),\n        existingTask.id,\n      );\n    });\n\n    it('re-throws createTask error when no existing task can be found', async () => {\n      const error = Object.assign(new Error('DB error'), {\n        code: 'SOME_OTHER_ERROR',\n      });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValueOnce(null); // fallback also finds nothing\n      taskState.createTask.mockRejectedValueOnce(error);\n\n      await expect(\n        service.route(makeEvent({ type: 'issue_assigned' })),\n      ).rejects.toThrow('DB error');\n    });\n\n    it('skips phase_ event re-execution when phase is failed and task is gated (BLOCKED:PERSISTENT retry)', async () => {\n      const task = makeTask({\n        status: 'gated',\n        gatePhase: 'deliver',\n        gateReason: 'Max retries exceeded',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n\n      // BullMQ stall-retry re-delivers the phase_deliver job\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('allows phase_ re-execution when phase is failed but task is NOT gated (transient retry)', async () => {\n      // InternalAdapterService sets phase to 'failed' and re-enqueues on transient block;\n      // task.status stays 'active'. This legitimate retry must proceed.\n      const task = makeTask({\n        status: 'active',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n      // Phase status is 'active' in-memory on the task passed into executePhase.\n      // This simulates a scenario where route()'s active-phase check was bypassed.\n      const task = makeTask({\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('calls invoke exactly once when route() processes the same event while phase is active', async () => {\n      const task = makeTask({ phaseRefine: 'pending' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // First getPhaseStatus call (route guard): 'pending' → proceed\n      // executePhase() defensive guard also returns 'pending' → proceed to invoke\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      hooks.onPhaseComplete.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n","structuredPatch":[{"oldStart":1263,"oldLines":7,"newStart":1263,"newLines":7,"lines":["     it('restart on non-active task: resets all phases and executes refine', async () => {","       const task = makeTask({","         status: 'failed',","-        currentPhase: 'execute',","+        currentPhase: 'deliver',","       });","       taskState.findByIssueAndRepo.mockResolvedValue(task);","       taskState.save.mockResolvedValue(task);"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01U5bMNaDJEKBphUmgVTgVef","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01K9KJ4mtQFngBKrnZPYzAYo","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","old_string":"    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });","new_string":"    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":317,"cache_read_input_tokens":101648,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":317},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"327d056b-4d9b-4092-84c4-045e6e59612c"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01K9KJ4mtQFngBKrnZPYzAYo","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"f0ebedfe-b51f-415a-b0d8-9414f2cf3d0e","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","oldString":"    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });","newString":"    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { PhaseRouterService } from './phase-router.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\nimport { ClaudeInvocationService } from '../invoke/claude-invocation.service';\nimport { WorktreeService } from '../worktree/worktree.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventService } from '../event/event.service';\nimport { GitHubService } from '../github/github.service';\nimport { TokenUsageService } from '../token-usage/token-usage.service';\nimport { ReleaseService } from '../release/release.service';\nimport { EventBusService } from '../event-bus';\nimport type { TaskEntity } from '../database/entities/task.entity';\nimport type { DispatchEvent } from '../queue/job-queue.interface';\n\nfunction makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n  return {\n    id: 1,\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    currentPhase: 'refine',\n    status: 'active',\n    gatePhase: null,\n    gateReason: null,\n    prNumber: null,\n    prBranch: null,\n    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseReview: 'pending',\n    phaseDeliver: 'pending',\n    phaseCompound: 'pending',\n    qualityGateOverride: false,\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    ...overrides,\n  } as TaskEntity;\n}\n\nfunction makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n  return {\n    type: 'issue_assigned',\n    issueNumber: 42,\n    repo: 'org/repo',\n    ...overrides,\n  };\n}\n\ndescribe('PhaseRouterService', () => {\n  let service: PhaseRouterService;\n  let taskState: jest.Mocked<TaskStateService>;\n  let internalAdapter: jest.Mocked<InternalAdapterService>;\n  let claude: jest.Mocked<ClaudeInvocationService>;\n  let worktreeService: jest.Mocked<WorktreeService>;\n  let artefacts: jest.Mocked<ArtefactsService>;\n  let hooks: jest.Mocked<PhaseHooksService>;\n  let eventService: jest.Mocked<EventService>;\n  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let releaseService: jest.Mocked<\n    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n  >;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        PhaseRouterService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByIssueAndRepo: jest.fn(),\n            createTask: jest.fn(),\n            save: jest.fn(),\n            getPhaseStatus: jest.fn(),\n            setPhaseStatus: jest.fn(),\n            setGate: jest.fn(),\n            clearGate: jest.fn(),\n            resetForRetry: jest.fn(),\n            resetForRestart: jest.fn(),\n            setQualityGateOverride: jest.fn(),\n            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: InternalAdapterService,\n          useValue: {\n            handleSignal: jest.fn(),\n            getNextPhase: jest.fn(),\n          },\n        },\n        {\n          provide: ClaudeInvocationService,\n          useValue: {\n            invoke: jest.fn(),\n          },\n        },\n        {\n          provide: WorktreeService,\n          useValue: {\n            getOrCreateWorktree: jest.fn(),\n            cleanupWorktree: jest.fn(),\n          },\n        },\n        {\n          provide: ArtefactsService,\n          useValue: {\n            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n            buildCentralTaskDir: jest\n              .fn()\n              .mockReturnValue(\n                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n              ),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n            onPhaseComplete: jest.fn(),\n            onPhaseSkip: jest.fn(),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn(),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: GitHubService,\n          useValue: {\n            postComment: jest.fn().mockResolvedValue(undefined),\n            getPRFiles: jest.fn().mockResolvedValue([]),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: TokenUsageService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get(PhaseRouterService);\n    taskState = module.get(TaskStateService);\n    internalAdapter = module.get(InternalAdapterService);\n    claude = module.get(ClaudeInvocationService);\n    worktreeService = module.get(WorktreeService);\n    artefacts = module.get(ArtefactsService);\n    hooks = module.get(PhaseHooksService);\n    eventService = module.get(EventService);\n    gitHub = module.get(GitHubService);\n    eventBus = module.get(EventBusService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  describe('route()', () => {\n    it('should create a new task when none exists and execute the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({\n          ISSUE_NUMBER: '42',\n          REPO: 'org/repo',\n          PHASE: 'refine',\n        }),\n        task.id,\n      );\n    });\n\n    it('should use existing task when found', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should use the directive from the event when creating a task', async () => {\n      const task = makeTask({ directive: 'careful' });\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ directive: 'careful' }));\n\n      expect(taskState.createTask).toHaveBeenCalledWith(\n        42,\n        'org/repo',\n        'careful',\n      );\n    });\n\n    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n      const task = makeTask({ directive: 'careful', currentPhase: 'plan' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      // Plan runs — gate is applied AFTER completion in advanceAndEnqueue, not before\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n      expect(taskState.setGate).not.toHaveBeenCalled();\n    });\n\n    it('should not gate before phase execution (gates are post-completion in internal-adapter)', async () => {\n      const task = makeTask({ directive: 'auto' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should not gate any phase with quick directive', async () => {\n      const task = makeTask({ directive: 'quick' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip when event type cannot be resolved to a phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'unknown_event' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip execution when phase is already active (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should execute phase when phase status is pending (first run)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip completed phase on internal phase_* event (dedup guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip skipped phase on internal phase_* event', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'skipped',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('skipped');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip phase execution when task has been paused by /agent stop', async () => {\n      // Simulate: task.status='paused' was set in DB while a phase_plan\n      // completion event was already in the queue.  The fresh DB read must win.\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'paused',\n        phasePlan: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent stop',\n          actor: 'zhfahan77',\n        }),\n      );\n\n      expect(taskState.save).toHaveBeenCalledWith(\n        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_paused',\n        'phase-router',\n        { actor: 'zhfahan77' },\n      );\n    });\n\n    it('should allow re-executing completed refine on issue_comment event', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_cancelled',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle task_cancelled with no existing task gracefully', async () => {\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(taskState.save).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockRejectedValue(\n        new Error('worktree not found'),\n      );\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      // Should not throw — cleanup failure is best-effort\n    });\n\n    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_reopened',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should clear gate and execute phase on directive event', async () => {\n      const task = makeTask({\n        status: 'gated',\n        directive: 'quick',\n        currentPhase: 'review',\n        gatePhase: 'review',\n        gateReason: 'Awaiting approval',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: 'approve' }),\n      );\n\n      expect(task.status).toBe('active');\n      expect(task.gatePhase).toBeNull();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'gate_released',\n        'phase-router',\n        expect.objectContaining({ directive: 'approve' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({ PHASE: 'review' }),\n        task.id,\n      );\n    });\n  });\n\n  describe('executePhase() via route()', () => {\n    it('should create a worktree for the deliver phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/worktree' }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree for the address_review phase', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix-review' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_address_review' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'address_review',\n        expect.objectContaining({\n          WORKTREE_PATH: '/tmp/worktree',\n          PR_NUMBER: '50',\n          PR_BRANCH: 'feat/fix-review',\n        }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree with agents-md prefix for compound phase', async () => {\n      const task = makeTask({ currentPhase: 'compound' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue(\n        '/tmp/compound-worktree',\n      );\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_compound' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'agents-md',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'compound',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/compound-worktree' }),\n        task.id,\n      );\n    });\n\n    it('should pass rcBranch to getOrCreateWorktree when an active release exists', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue({\n        releaseId: 7,\n        key: 'RC-1',\n        title: 'Release 1',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v1.2',\n        baseBranch: 'master',\n      });\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        'rc/v1.2',\n      );\n    });\n\n    it('should pass undefined baseBranch to getOrCreateWorktree when no release is active', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue(null);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n    });\n\n    it('should not create a worktree for non-execute phases', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(worktreeService.getOrCreateWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree creation failure with transient block', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockRejectedValue(\n        new Error('disk full'),\n      );\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(internalAdapter.handleSignal).toHaveBeenCalledWith(\n        task.id,\n        'deliver',\n        'BLOCKED:TRANSIENT',\n        expect.stringContaining('disk full'),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should set phase status to active before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'plan',\n        'active',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('should include PR env vars when task has prNumber and prBranch', async () => {\n      const task = makeTask({\n        prNumber: 99,\n        prBranch: 'feat/test',\n        directive: 'quick',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_review' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({\n          PR_NUMBER: '99',\n          PR_BRANCH: 'feat/test',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include TASK_DIR env var pointing to central path', async () => {\n      const centralPath =\n        '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42';\n      artefacts.buildCentralTaskDir.mockReturnValue(centralPath);\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(artefacts.buildCentralTaskDir).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_DIR: centralPath }),\n        task.id,\n      );\n    });\n\n    it('should call onPhaseStart before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      const callOrder: string[] = [];\n      hooks.onPhaseStart.mockImplementation((): Promise<void> => {\n        callOrder.push('onPhaseStart');\n        return Promise.resolve();\n      });\n      claude.invoke.mockImplementation((): Promise<void> => {\n        callOrder.push('claude');\n        return Promise.resolve();\n      });\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(hooks.onPhaseStart).toHaveBeenCalledWith(\n        expect.objectContaining({ issue: 42, repo: 'org/repo' }),\n        'refine',\n      );\n      expect(callOrder).toEqual(['onPhaseStart', 'claude']);\n    });\n\n    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n\n      await expect(\n        service.route(makeEvent({ type: 'phase_deliver' })),\n      ).rejects.toThrow('Claude crash');\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should not cleanup worktree after normal phase execution', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('resolvePhase()', () => {\n    const task = makeTask();\n\n    it('should resolve issue_assigned to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve intake to refine', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n        'refine',\n      );\n    });\n\n    it('should resolve issue_comment to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n          task,\n        ),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve phase_* events to the phase name', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n      ).toBe('refine');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n      ).toBe('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n      ).toBe('deliver');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n      ).toBe('compound');\n    });\n\n    it('should resolve _completed events to the next phase', () => {\n      internalAdapter.getNextPhase.mockReturnValue('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'refine_completed' }), task),\n      ).toBe('plan');\n      expect(internalAdapter.getNextPhase).toHaveBeenCalledWith('refine');\n    });\n\n    it('should resolve pr_merged to compound', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'pr_merged' }), task)).toBe(\n        'compound',\n      );\n    });\n\n    it('should resolve review_approved to null (handled specially in route)', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'review_approved' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve review_changes_requested to address_review', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'review_changes_requested' }),\n          task,\n        ),\n      ).toBe('address_review');\n    });\n\n    it('should resolve ci_passed to deliver', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'ci_passed' }), task)).toBe(\n        'deliver',\n      );\n    });\n\n    it('should resolve ci_failure to deliver', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'ci_failure' }), task),\n      ).toBe('deliver');\n    });\n\n    it('should resolve gate_released to the gated phase', () => {\n      const gatedTask = makeTask({ gatePhase: 'plan' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'gate_released' }), gatedTask),\n      ).toBe('plan');\n    });\n\n    it('should resolve pr_comment to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'pr_comment' }), task),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve task_cancelled to null', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_cancelled' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve task_reopened to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_reopened' }), task),\n      ).toBe('refine');\n    });\n\n    it('should return null for unknown events', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'something_unknown' }), task),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already blocked', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'refine',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), blockedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already gated', () => {\n      const gatedTask = makeTask({\n        status: 'gated',\n        currentPhase: 'review',\n        gatePhase: 'review',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), gatedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already past refine', () => {\n      const planTask = makeTask({ currentPhase: 'plan', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), planTask),\n      ).toBeNull();\n    });\n\n    it('should allow intake when task is still at refine', () => {\n      const refineTask = makeTask({ currentPhase: 'refine', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n      ).toBe('refine');\n    });\n\n    it('should resolve directive to gated phase for gated tasks', () => {\n      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n      ).toBe('review');\n    });\n\n    it('should return null for directive on non-gated tasks', () => {\n      const activeTask = makeTask({ status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n      ).toBeNull();\n    });\n\n    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('deliver');\n    });\n\n    it('should resolve retry directive from event.directive field', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'retry' }),\n          stuckTask,\n        ),\n      ).toBe('plan');\n    });\n\n    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'deliver',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n          blockedTask,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve restart directive from event.directive field', () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'restart' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via event.directive field', () => {\n      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should return null for retry on task with no currentPhase', () => {\n      const taskNoPhase = makeTask({\n        status: 'active',\n        currentPhase: null as unknown as string,\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          taskNoPhase,\n        ),\n      ).toBeNull();\n    });\n  });\n\n  // isGated() removed — gate logic moved to advanceAndEnqueue() in internal-adapter.\n  // Gate tests are now in internal-adapter.service.spec.ts.\n\n  // signal mapping tests removed — result handling moved to InvocationResultListener.\n\n  describe('issue_comment routing', () => {\n    it('should route issue_comment events to the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should route issue_comment with prNumber to reply_to_pr_comment with COMMENT_ID in env', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PHASE: 'reply_to_pr_comment',\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include PR_NUMBER from event when task has no prNumber', async () => {\n      const task = makeTask({ prNumber: null });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should not include AGENT_PERSONA in env for any phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('AGENT_PERSONA');\n    });\n  });\n\n  describe('review phase status on external review events', () => {\n    it('marks review phase complete when review_approved routes to deliver', async () => {\n      // Use quick directive so deliver is not gated and Claude gets invoked\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('marks review phase complete when review_changes_requested routes to address_review', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // address_review phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'review_changes_requested' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n\n    it('skips review phase status update when review is already complete', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('complete') // review status — already complete\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      // setPhaseStatus should NOT be called for review since it's already complete\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n  });\n\n  describe('event recording', () => {\n    it('should record phase_started event', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'phase_started',\n        'phase-router',\n        { phase: 'refine' },\n      );\n    });\n\n    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n    // after the phase completes, not by phase-router before execution.\n  });\n\n  describe('retry/restart directive handling', () => {\n    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n        phaseDeliver: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ PHASE: 'deliver' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {\n      const task = makeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n    });\n\n    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent retry',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Retry rejected'),\n      );\n    });\n\n    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'deliver',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'execute' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent restart',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Restart rejected'),\n      );\n    });\n\n    it('unknown directive: posts feedback comment listing valid directives', async () => {\n      const task = makeTask({ status: 'active' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent foobar',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('retry'),\n      );\n    });\n  });\n\n  describe('quality gate override', () => {\n    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n        task.id,\n      );\n    });\n\n    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n    });\n\n    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        false,\n      );\n    });\n  });\n\n  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n\n  describe('idempotency guards', () => {\n    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'blocked',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'Awaiting approval',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        gatePhase: null,\n        gateReason: null,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent approve',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();\n    });\n\n    // artefact sync tests removed — artefact handling moved to InvocationResultListener.\n\n    it('should ignore intake event when task is already complete (late duplicate guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'compound',\n        status: 'complete',\n        phaseCompound: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    it('calls createTask once and invoke once when route() is called twice with same event', async () => {\n      const task = makeTask();\n      // First call: no task exists → create\n      // Second call: task now exists → reuse\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValue(task);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      const event = makeEvent({ type: 'issue_assigned' });\n      await service.route(event);\n      await service.route(event);\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Second call: phase is still 'pending' so invoke runs again (not prevented by active guard),\n      // but the idempotency contract for external events allows this. The important guarantee is\n      // createTask is not called twice.\n      expect(claude.invoke).toHaveBeenCalledTimes(2);\n    });\n\n    it('falls back to existing task when createTask throws unique-constraint error', async () => {\n      const existingTask = makeTask({ id: 5 });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null) // first call: no task\n        .mockResolvedValue(existingTask); // fallback + subsequent calls (paused check etc.)\n      taskState.createTask.mockRejectedValueOnce(\n        Object.assign(new Error('UNIQUE constraint failed'), {\n          code: 'SQLITE_CONSTRAINT',\n        }),\n      );\n      taskState.save.mockResolvedValue(existingTask);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_assigned' }));\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Falls back to the existing task and continues execution\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_ID: String(existingTask.id) }),\n        existingTask.id,\n      );\n    });\n\n    it('re-throws createTask error when no existing task can be found', async () => {\n      const error = Object.assign(new Error('DB error'), {\n        code: 'SOME_OTHER_ERROR',\n      });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValueOnce(null); // fallback also finds nothing\n      taskState.createTask.mockRejectedValueOnce(error);\n\n      await expect(\n        service.route(makeEvent({ type: 'issue_assigned' })),\n      ).rejects.toThrow('DB error');\n    });\n\n    it('skips phase_ event re-execution when phase is failed and task is gated (BLOCKED:PERSISTENT retry)', async () => {\n      const task = makeTask({\n        status: 'gated',\n        gatePhase: 'deliver',\n        gateReason: 'Max retries exceeded',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n\n      // BullMQ stall-retry re-delivers the phase_deliver job\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('allows phase_ re-execution when phase is failed but task is NOT gated (transient retry)', async () => {\n      // InternalAdapterService sets phase to 'failed' and re-enqueues on transient block;\n      // task.status stays 'active'. This legitimate retry must proceed.\n      const task = makeTask({\n        status: 'active',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n      // Phase status is 'active' in-memory on the task passed into executePhase.\n      // This simulates a scenario where route()'s active-phase check was bypassed.\n      const task = makeTask({\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('calls invoke exactly once when route() processes the same event while phase is active', async () => {\n      const task = makeTask({ phaseRefine: 'pending' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // First getPhaseStatus call (route guard): 'pending' → proceed\n      // executePhase() defensive guard also returns 'pending' → proceed to invoke\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      hooks.onPhaseComplete.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n","structuredPatch":[{"oldStart":1284,"oldLines":7,"newStart":1284,"newLines":7,"lines":["     });"," ","     it('restart on active task: rejects with feedback comment, no dispatch', async () => {","-      const task = makeTask({ status: 'active', currentPhase: 'execute' });","+      const task = makeTask({ status: 'active', currentPhase: 'deliver' });","       taskState.findByIssueAndRepo.mockResolvedValue(task);"," ","       await service.route("]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_016guBu4f85W3WW79qNX6Txn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PvkkrFTb2UXe2gKrGDDNTY","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","old_string":"    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });","new_string":"    it('should ignore intake event when task is in deliver phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'deliver',\n        status: 'active',\n        phaseDeliver: 'active',\n      });"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":299,"cache_read_input_tokens":101965,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":299},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"5db88d34-2963-4b6f-b729-4994c6ee7d23"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PvkkrFTb2UXe2gKrGDDNTY","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"0c022754-93c7-4993-8a6b-6d7ce7be1d76","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","oldString":"    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });","newString":"    it('should ignore intake event when task is in deliver phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'deliver',\n        status: 'active',\n        phaseDeliver: 'active',\n      });","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { PhaseRouterService } from './phase-router.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\nimport { ClaudeInvocationService } from '../invoke/claude-invocation.service';\nimport { WorktreeService } from '../worktree/worktree.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventService } from '../event/event.service';\nimport { GitHubService } from '../github/github.service';\nimport { TokenUsageService } from '../token-usage/token-usage.service';\nimport { ReleaseService } from '../release/release.service';\nimport { EventBusService } from '../event-bus';\nimport type { TaskEntity } from '../database/entities/task.entity';\nimport type { DispatchEvent } from '../queue/job-queue.interface';\n\nfunction makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n  return {\n    id: 1,\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    currentPhase: 'refine',\n    status: 'active',\n    gatePhase: null,\n    gateReason: null,\n    prNumber: null,\n    prBranch: null,\n    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseReview: 'pending',\n    phaseDeliver: 'pending',\n    phaseCompound: 'pending',\n    qualityGateOverride: false,\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    ...overrides,\n  } as TaskEntity;\n}\n\nfunction makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n  return {\n    type: 'issue_assigned',\n    issueNumber: 42,\n    repo: 'org/repo',\n    ...overrides,\n  };\n}\n\ndescribe('PhaseRouterService', () => {\n  let service: PhaseRouterService;\n  let taskState: jest.Mocked<TaskStateService>;\n  let internalAdapter: jest.Mocked<InternalAdapterService>;\n  let claude: jest.Mocked<ClaudeInvocationService>;\n  let worktreeService: jest.Mocked<WorktreeService>;\n  let artefacts: jest.Mocked<ArtefactsService>;\n  let hooks: jest.Mocked<PhaseHooksService>;\n  let eventService: jest.Mocked<EventService>;\n  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let releaseService: jest.Mocked<\n    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n  >;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        PhaseRouterService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByIssueAndRepo: jest.fn(),\n            createTask: jest.fn(),\n            save: jest.fn(),\n            getPhaseStatus: jest.fn(),\n            setPhaseStatus: jest.fn(),\n            setGate: jest.fn(),\n            clearGate: jest.fn(),\n            resetForRetry: jest.fn(),\n            resetForRestart: jest.fn(),\n            setQualityGateOverride: jest.fn(),\n            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: InternalAdapterService,\n          useValue: {\n            handleSignal: jest.fn(),\n            getNextPhase: jest.fn(),\n          },\n        },\n        {\n          provide: ClaudeInvocationService,\n          useValue: {\n            invoke: jest.fn(),\n          },\n        },\n        {\n          provide: WorktreeService,\n          useValue: {\n            getOrCreateWorktree: jest.fn(),\n            cleanupWorktree: jest.fn(),\n          },\n        },\n        {\n          provide: ArtefactsService,\n          useValue: {\n            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n            buildCentralTaskDir: jest\n              .fn()\n              .mockReturnValue(\n                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n              ),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n            onPhaseComplete: jest.fn(),\n            onPhaseSkip: jest.fn(),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn(),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: GitHubService,\n          useValue: {\n            postComment: jest.fn().mockResolvedValue(undefined),\n            getPRFiles: jest.fn().mockResolvedValue([]),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: TokenUsageService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get(PhaseRouterService);\n    taskState = module.get(TaskStateService);\n    internalAdapter = module.get(InternalAdapterService);\n    claude = module.get(ClaudeInvocationService);\n    worktreeService = module.get(WorktreeService);\n    artefacts = module.get(ArtefactsService);\n    hooks = module.get(PhaseHooksService);\n    eventService = module.get(EventService);\n    gitHub = module.get(GitHubService);\n    eventBus = module.get(EventBusService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  describe('route()', () => {\n    it('should create a new task when none exists and execute the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({\n          ISSUE_NUMBER: '42',\n          REPO: 'org/repo',\n          PHASE: 'refine',\n        }),\n        task.id,\n      );\n    });\n\n    it('should use existing task when found', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should use the directive from the event when creating a task', async () => {\n      const task = makeTask({ directive: 'careful' });\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ directive: 'careful' }));\n\n      expect(taskState.createTask).toHaveBeenCalledWith(\n        42,\n        'org/repo',\n        'careful',\n      );\n    });\n\n    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n      const task = makeTask({ directive: 'careful', currentPhase: 'plan' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      // Plan runs — gate is applied AFTER completion in advanceAndEnqueue, not before\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n      expect(taskState.setGate).not.toHaveBeenCalled();\n    });\n\n    it('should not gate before phase execution (gates are post-completion in internal-adapter)', async () => {\n      const task = makeTask({ directive: 'auto' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should not gate any phase with quick directive', async () => {\n      const task = makeTask({ directive: 'quick' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip when event type cannot be resolved to a phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'unknown_event' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip execution when phase is already active (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should execute phase when phase status is pending (first run)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip completed phase on internal phase_* event (dedup guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip skipped phase on internal phase_* event', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'skipped',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('skipped');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip phase execution when task has been paused by /agent stop', async () => {\n      // Simulate: task.status='paused' was set in DB while a phase_plan\n      // completion event was already in the queue.  The fresh DB read must win.\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'paused',\n        phasePlan: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent stop',\n          actor: 'zhfahan77',\n        }),\n      );\n\n      expect(taskState.save).toHaveBeenCalledWith(\n        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_paused',\n        'phase-router',\n        { actor: 'zhfahan77' },\n      );\n    });\n\n    it('should allow re-executing completed refine on issue_comment event', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_cancelled',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle task_cancelled with no existing task gracefully', async () => {\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(taskState.save).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockRejectedValue(\n        new Error('worktree not found'),\n      );\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      // Should not throw — cleanup failure is best-effort\n    });\n\n    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_reopened',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should clear gate and execute phase on directive event', async () => {\n      const task = makeTask({\n        status: 'gated',\n        directive: 'quick',\n        currentPhase: 'review',\n        gatePhase: 'review',\n        gateReason: 'Awaiting approval',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: 'approve' }),\n      );\n\n      expect(task.status).toBe('active');\n      expect(task.gatePhase).toBeNull();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'gate_released',\n        'phase-router',\n        expect.objectContaining({ directive: 'approve' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({ PHASE: 'review' }),\n        task.id,\n      );\n    });\n  });\n\n  describe('executePhase() via route()', () => {\n    it('should create a worktree for the deliver phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/worktree' }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree for the address_review phase', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix-review' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_address_review' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'address_review',\n        expect.objectContaining({\n          WORKTREE_PATH: '/tmp/worktree',\n          PR_NUMBER: '50',\n          PR_BRANCH: 'feat/fix-review',\n        }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree with agents-md prefix for compound phase', async () => {\n      const task = makeTask({ currentPhase: 'compound' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue(\n        '/tmp/compound-worktree',\n      );\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_compound' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'agents-md',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'compound',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/compound-worktree' }),\n        task.id,\n      );\n    });\n\n    it('should pass rcBranch to getOrCreateWorktree when an active release exists', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue({\n        releaseId: 7,\n        key: 'RC-1',\n        title: 'Release 1',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v1.2',\n        baseBranch: 'master',\n      });\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        'rc/v1.2',\n      );\n    });\n\n    it('should pass undefined baseBranch to getOrCreateWorktree when no release is active', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue(null);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n    });\n\n    it('should not create a worktree for non-execute phases', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(worktreeService.getOrCreateWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree creation failure with transient block', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockRejectedValue(\n        new Error('disk full'),\n      );\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(internalAdapter.handleSignal).toHaveBeenCalledWith(\n        task.id,\n        'deliver',\n        'BLOCKED:TRANSIENT',\n        expect.stringContaining('disk full'),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should set phase status to active before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'plan',\n        'active',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('should include PR env vars when task has prNumber and prBranch', async () => {\n      const task = makeTask({\n        prNumber: 99,\n        prBranch: 'feat/test',\n        directive: 'quick',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_review' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({\n          PR_NUMBER: '99',\n          PR_BRANCH: 'feat/test',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include TASK_DIR env var pointing to central path', async () => {\n      const centralPath =\n        '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42';\n      artefacts.buildCentralTaskDir.mockReturnValue(centralPath);\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(artefacts.buildCentralTaskDir).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_DIR: centralPath }),\n        task.id,\n      );\n    });\n\n    it('should call onPhaseStart before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      const callOrder: string[] = [];\n      hooks.onPhaseStart.mockImplementation((): Promise<void> => {\n        callOrder.push('onPhaseStart');\n        return Promise.resolve();\n      });\n      claude.invoke.mockImplementation((): Promise<void> => {\n        callOrder.push('claude');\n        return Promise.resolve();\n      });\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(hooks.onPhaseStart).toHaveBeenCalledWith(\n        expect.objectContaining({ issue: 42, repo: 'org/repo' }),\n        'refine',\n      );\n      expect(callOrder).toEqual(['onPhaseStart', 'claude']);\n    });\n\n    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n\n      await expect(\n        service.route(makeEvent({ type: 'phase_deliver' })),\n      ).rejects.toThrow('Claude crash');\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should not cleanup worktree after normal phase execution', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('resolvePhase()', () => {\n    const task = makeTask();\n\n    it('should resolve issue_assigned to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve intake to refine', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n        'refine',\n      );\n    });\n\n    it('should resolve issue_comment to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n          task,\n        ),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve phase_* events to the phase name', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n      ).toBe('refine');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n      ).toBe('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n      ).toBe('deliver');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n      ).toBe('compound');\n    });\n\n    it('should resolve _completed events to the next phase', () => {\n      internalAdapter.getNextPhase.mockReturnValue('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'refine_completed' }), task),\n      ).toBe('plan');\n      expect(internalAdapter.getNextPhase).toHaveBeenCalledWith('refine');\n    });\n\n    it('should resolve pr_merged to compound', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'pr_merged' }), task)).toBe(\n        'compound',\n      );\n    });\n\n    it('should resolve review_approved to null (handled specially in route)', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'review_approved' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve review_changes_requested to address_review', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'review_changes_requested' }),\n          task,\n        ),\n      ).toBe('address_review');\n    });\n\n    it('should resolve ci_passed to deliver', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'ci_passed' }), task)).toBe(\n        'deliver',\n      );\n    });\n\n    it('should resolve ci_failure to deliver', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'ci_failure' }), task),\n      ).toBe('deliver');\n    });\n\n    it('should resolve gate_released to the gated phase', () => {\n      const gatedTask = makeTask({ gatePhase: 'plan' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'gate_released' }), gatedTask),\n      ).toBe('plan');\n    });\n\n    it('should resolve pr_comment to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'pr_comment' }), task),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve task_cancelled to null', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_cancelled' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve task_reopened to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_reopened' }), task),\n      ).toBe('refine');\n    });\n\n    it('should return null for unknown events', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'something_unknown' }), task),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already blocked', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'refine',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), blockedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already gated', () => {\n      const gatedTask = makeTask({\n        status: 'gated',\n        currentPhase: 'review',\n        gatePhase: 'review',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), gatedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already past refine', () => {\n      const planTask = makeTask({ currentPhase: 'plan', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), planTask),\n      ).toBeNull();\n    });\n\n    it('should allow intake when task is still at refine', () => {\n      const refineTask = makeTask({ currentPhase: 'refine', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n      ).toBe('refine');\n    });\n\n    it('should resolve directive to gated phase for gated tasks', () => {\n      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n      ).toBe('review');\n    });\n\n    it('should return null for directive on non-gated tasks', () => {\n      const activeTask = makeTask({ status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n      ).toBeNull();\n    });\n\n    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('deliver');\n    });\n\n    it('should resolve retry directive from event.directive field', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'retry' }),\n          stuckTask,\n        ),\n      ).toBe('plan');\n    });\n\n    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'deliver',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n          blockedTask,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve restart directive from event.directive field', () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'restart' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via event.directive field', () => {\n      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should return null for retry on task with no currentPhase', () => {\n      const taskNoPhase = makeTask({\n        status: 'active',\n        currentPhase: null as unknown as string,\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          taskNoPhase,\n        ),\n      ).toBeNull();\n    });\n  });\n\n  // isGated() removed — gate logic moved to advanceAndEnqueue() in internal-adapter.\n  // Gate tests are now in internal-adapter.service.spec.ts.\n\n  // signal mapping tests removed — result handling moved to InvocationResultListener.\n\n  describe('issue_comment routing', () => {\n    it('should route issue_comment events to the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should route issue_comment with prNumber to reply_to_pr_comment with COMMENT_ID in env', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PHASE: 'reply_to_pr_comment',\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include PR_NUMBER from event when task has no prNumber', async () => {\n      const task = makeTask({ prNumber: null });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should not include AGENT_PERSONA in env for any phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('AGENT_PERSONA');\n    });\n  });\n\n  describe('review phase status on external review events', () => {\n    it('marks review phase complete when review_approved routes to deliver', async () => {\n      // Use quick directive so deliver is not gated and Claude gets invoked\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('marks review phase complete when review_changes_requested routes to address_review', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // address_review phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'review_changes_requested' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n\n    it('skips review phase status update when review is already complete', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('complete') // review status — already complete\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      // setPhaseStatus should NOT be called for review since it's already complete\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n  });\n\n  describe('event recording', () => {\n    it('should record phase_started event', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'phase_started',\n        'phase-router',\n        { phase: 'refine' },\n      );\n    });\n\n    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n    // after the phase completes, not by phase-router before execution.\n  });\n\n  describe('retry/restart directive handling', () => {\n    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n        phaseDeliver: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ PHASE: 'deliver' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {\n      const task = makeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n    });\n\n    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent retry',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Retry rejected'),\n      );\n    });\n\n    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'deliver',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent restart',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Restart rejected'),\n      );\n    });\n\n    it('unknown directive: posts feedback comment listing valid directives', async () => {\n      const task = makeTask({ status: 'active' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent foobar',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('retry'),\n      );\n    });\n  });\n\n  describe('quality gate override', () => {\n    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n        task.id,\n      );\n    });\n\n    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n    });\n\n    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        false,\n      );\n    });\n  });\n\n  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n\n  describe('idempotency guards', () => {\n    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        phaseExecute: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'blocked',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'Awaiting approval',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        gatePhase: null,\n        gateReason: null,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent approve',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();\n    });\n\n    // artefact sync tests removed — artefact handling moved to InvocationResultListener.\n\n    it('should ignore intake event when task is already complete (late duplicate guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'compound',\n        status: 'complete',\n        phaseCompound: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    it('calls createTask once and invoke once when route() is called twice with same event', async () => {\n      const task = makeTask();\n      // First call: no task exists → create\n      // Second call: task now exists → reuse\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValue(task);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      const event = makeEvent({ type: 'issue_assigned' });\n      await service.route(event);\n      await service.route(event);\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Second call: phase is still 'pending' so invoke runs again (not prevented by active guard),\n      // but the idempotency contract for external events allows this. The important guarantee is\n      // createTask is not called twice.\n      expect(claude.invoke).toHaveBeenCalledTimes(2);\n    });\n\n    it('falls back to existing task when createTask throws unique-constraint error', async () => {\n      const existingTask = makeTask({ id: 5 });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null) // first call: no task\n        .mockResolvedValue(existingTask); // fallback + subsequent calls (paused check etc.)\n      taskState.createTask.mockRejectedValueOnce(\n        Object.assign(new Error('UNIQUE constraint failed'), {\n          code: 'SQLITE_CONSTRAINT',\n        }),\n      );\n      taskState.save.mockResolvedValue(existingTask);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_assigned' }));\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Falls back to the existing task and continues execution\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_ID: String(existingTask.id) }),\n        existingTask.id,\n      );\n    });\n\n    it('re-throws createTask error when no existing task can be found', async () => {\n      const error = Object.assign(new Error('DB error'), {\n        code: 'SOME_OTHER_ERROR',\n      });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValueOnce(null); // fallback also finds nothing\n      taskState.createTask.mockRejectedValueOnce(error);\n\n      await expect(\n        service.route(makeEvent({ type: 'issue_assigned' })),\n      ).rejects.toThrow('DB error');\n    });\n\n    it('skips phase_ event re-execution when phase is failed and task is gated (BLOCKED:PERSISTENT retry)', async () => {\n      const task = makeTask({\n        status: 'gated',\n        gatePhase: 'deliver',\n        gateReason: 'Max retries exceeded',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n\n      // BullMQ stall-retry re-delivers the phase_deliver job\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('allows phase_ re-execution when phase is failed but task is NOT gated (transient retry)', async () => {\n      // InternalAdapterService sets phase to 'failed' and re-enqueues on transient block;\n      // task.status stays 'active'. This legitimate retry must proceed.\n      const task = makeTask({\n        status: 'active',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n      // Phase status is 'active' in-memory on the task passed into executePhase.\n      // This simulates a scenario where route()'s active-phase check was bypassed.\n      const task = makeTask({\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('calls invoke exactly once when route() processes the same event while phase is active', async () => {\n      const task = makeTask({ phaseRefine: 'pending' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // First getPhaseStatus call (route guard): 'pending' → proceed\n      // executePhase() defensive guard also returns 'pending' → proceed to invoke\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      hooks.onPhaseComplete.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n","structuredPatch":[{"oldStart":1395,"oldLines":11,"newStart":1395,"newLines":11,"lines":["       expect(claude.invoke).not.toHaveBeenCalled();","     });"," ","-    it('should ignore intake event when task is in execute phase (duplicate intake guard)', async () => {","+    it('should ignore intake event when task is in deliver phase (duplicate intake guard)', async () => {","       const task = makeTask({","-        currentPhase: 'execute',","+        currentPhase: 'deliver',","         status: 'active',","-        phaseExecute: 'active',","+        phaseDeliver: 'active',","       });","       taskState.findByIssueAndRepo.mockResolvedValue(task);"," "]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01JnChLLcvn1a11gqg3C8GZq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01D2e1GgPVFv1Ahz8gGpcX96","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","old_string":"    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',","new_string":"    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'deliver',\n        status: 'active',"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":335,"cache_read_input_tokens":102264,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":335},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"545186e1-bcf2-46e4-b7b3-d4a27b74ba02"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01D2e1GgPVFv1Ahz8gGpcX96","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"0065032d-0108-40aa-9dda-e8795703d405","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/phase-router/phase-router.service.spec.ts","oldString":"    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',","newString":"    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'deliver',\n        status: 'active',","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { PhaseRouterService } from './phase-router.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { InternalAdapterService } from '../internal-adapter/internal-adapter.service';\nimport { ClaudeInvocationService } from '../invoke/claude-invocation.service';\nimport { WorktreeService } from '../worktree/worktree.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventService } from '../event/event.service';\nimport { GitHubService } from '../github/github.service';\nimport { TokenUsageService } from '../token-usage/token-usage.service';\nimport { ReleaseService } from '../release/release.service';\nimport { EventBusService } from '../event-bus';\nimport type { TaskEntity } from '../database/entities/task.entity';\nimport type { DispatchEvent } from '../queue/job-queue.interface';\n\nfunction makeTask(overrides: Partial<TaskEntity> = {}): TaskEntity {\n  return {\n    id: 1,\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    currentPhase: 'refine',\n    status: 'active',\n    gatePhase: null,\n    gateReason: null,\n    prNumber: null,\n    prBranch: null,\n    phaseRefine: 'pending',\n    phasePlan: 'pending',\n    phaseReview: 'pending',\n    phaseDeliver: 'pending',\n    phaseCompound: 'pending',\n    qualityGateOverride: false,\n    createdAt: new Date(),\n    updatedAt: new Date(),\n    ...overrides,\n  } as TaskEntity;\n}\n\nfunction makeEvent(overrides: Partial<DispatchEvent> = {}): DispatchEvent {\n  return {\n    type: 'issue_assigned',\n    issueNumber: 42,\n    repo: 'org/repo',\n    ...overrides,\n  };\n}\n\ndescribe('PhaseRouterService', () => {\n  let service: PhaseRouterService;\n  let taskState: jest.Mocked<TaskStateService>;\n  let internalAdapter: jest.Mocked<InternalAdapterService>;\n  let claude: jest.Mocked<ClaudeInvocationService>;\n  let worktreeService: jest.Mocked<WorktreeService>;\n  let artefacts: jest.Mocked<ArtefactsService>;\n  let hooks: jest.Mocked<PhaseHooksService>;\n  let eventService: jest.Mocked<EventService>;\n  let gitHub: jest.Mocked<Pick<GitHubService, 'postComment' | 'getPRFiles'>>;\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let releaseService: jest.Mocked<\n    Pick<ReleaseService, 'findActiveForTask' | 'autoLinkToRelease'>\n  >;\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        PhaseRouterService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByIssueAndRepo: jest.fn(),\n            createTask: jest.fn(),\n            save: jest.fn(),\n            getPhaseStatus: jest.fn(),\n            setPhaseStatus: jest.fn(),\n            setGate: jest.fn(),\n            clearGate: jest.fn(),\n            resetForRetry: jest.fn(),\n            resetForRestart: jest.fn(),\n            setQualityGateOverride: jest.fn(),\n            updatePrMetadata: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: InternalAdapterService,\n          useValue: {\n            handleSignal: jest.fn(),\n            getNextPhase: jest.fn(),\n          },\n        },\n        {\n          provide: ClaudeInvocationService,\n          useValue: {\n            invoke: jest.fn(),\n          },\n        },\n        {\n          provide: WorktreeService,\n          useValue: {\n            getOrCreateWorktree: jest.fn(),\n            cleanupWorktree: jest.fn(),\n          },\n        },\n        {\n          provide: ArtefactsService,\n          useValue: {\n            syncPhaseArtefacts: jest.fn().mockResolvedValue(undefined),\n            finalizeArtefacts: jest.fn().mockResolvedValue(undefined),\n            cleanupArtefactWorktree: jest.fn().mockResolvedValue(undefined),\n            buildCentralTaskDir: jest\n              .fn()\n              .mockReturnValue(\n                '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42',\n              ),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseStart: jest.fn().mockResolvedValue(undefined),\n            onPhaseComplete: jest.fn(),\n            onPhaseSkip: jest.fn(),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn(),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: GitHubService,\n          useValue: {\n            postComment: jest.fn().mockResolvedValue(undefined),\n            getPRFiles: jest.fn().mockResolvedValue([]),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: TokenUsageService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n            autoLinkToRelease: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get(PhaseRouterService);\n    taskState = module.get(TaskStateService);\n    internalAdapter = module.get(InternalAdapterService);\n    claude = module.get(ClaudeInvocationService);\n    worktreeService = module.get(WorktreeService);\n    artefacts = module.get(ArtefactsService);\n    hooks = module.get(PhaseHooksService);\n    eventService = module.get(EventService);\n    gitHub = module.get(GitHubService);\n    eventBus = module.get(EventBusService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  describe('route()', () => {\n    it('should create a new task when none exists and execute the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).toHaveBeenCalledWith(42, 'org/repo', 'auto');\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({\n          ISSUE_NUMBER: '42',\n          REPO: 'org/repo',\n          PHASE: 'refine',\n        }),\n        task.id,\n      );\n    });\n\n    it('should use existing task when found', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent());\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should use the directive from the event when creating a task', async () => {\n      const task = makeTask({ directive: 'careful' });\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ directive: 'careful' }));\n\n      expect(taskState.createTask).toHaveBeenCalledWith(\n        42,\n        'org/repo',\n        'careful',\n      );\n    });\n\n    it('should execute plan phase even with careful directive (gate is post-completion)', async () => {\n      const task = makeTask({ directive: 'careful', currentPhase: 'plan' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      // Plan runs — gate is applied AFTER completion in advanceAndEnqueue, not before\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n      expect(taskState.setGate).not.toHaveBeenCalled();\n    });\n\n    it('should not gate before phase execution (gates are post-completion in internal-adapter)', async () => {\n      const task = makeTask({ directive: 'auto' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should not gate any phase with quick directive', async () => {\n      const task = makeTask({ directive: 'quick' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setGate).not.toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip when event type cannot be resolved to a phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'unknown_event' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip execution when phase is already active (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should execute phase when phase status is pending (first run)', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should skip completed phase on internal phase_* event (dedup guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip skipped phase on internal phase_* event', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'skipped',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('skipped');\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should skip phase execution when task has been paused by /agent stop', async () => {\n      // Simulate: task.status='paused' was set in DB while a phase_plan\n      // completion event was already in the queue.  The fresh DB read must win.\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'paused',\n        phasePlan: 'pending',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should pause task and return early when /agent stop directive arrives', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent stop',\n          actor: 'zhfahan77',\n        }),\n      );\n\n      expect(taskState.save).toHaveBeenCalledWith(\n        expect.objectContaining({ status: 'paused', directive: 'stop' }),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_paused',\n        'phase-router',\n        { actor: 'zhfahan77' },\n      );\n    });\n\n    it('should allow re-executing completed refine on issue_comment event', async () => {\n      const task = makeTask({\n        currentPhase: 'refine',\n        status: 'active',\n        phaseRefine: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('complete');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('should cancel task on task_cancelled event', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_cancelled',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(worktreeService.cleanupWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle task_cancelled with no existing task gracefully', async () => {\n      taskState.findByIssueAndRepo.mockResolvedValue(null);\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(taskState.save).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree cleanup failure on cancel gracefully', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.cleanupWorktree.mockRejectedValue(\n        new Error('worktree not found'),\n      );\n\n      await service.route(\n        makeEvent({ type: 'task_cancelled', actor: 'human-dev' }),\n      );\n\n      expect(task.status).toBe('cancelled');\n      expect(taskState.save).toHaveBeenCalled();\n      // Should not throw — cleanup failure is best-effort\n    });\n\n    it('should reset cancelled task and route to refine on task_reopened', async () => {\n      const task = makeTask({ status: 'cancelled', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'task_reopened', actor: 'human-dev' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'task_reopened',\n        'phase-router',\n        expect.objectContaining({ actor: 'human-dev' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should clear gate and execute phase on directive event', async () => {\n      const task = makeTask({\n        status: 'gated',\n        directive: 'quick',\n        currentPhase: 'review',\n        gatePhase: 'review',\n        gateReason: 'Awaiting approval',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: 'approve' }),\n      );\n\n      expect(task.status).toBe('active');\n      expect(task.gatePhase).toBeNull();\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'gate_released',\n        'phase-router',\n        expect.objectContaining({ directive: 'approve' }),\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({ PHASE: 'review' }),\n        task.id,\n      );\n    });\n  });\n\n  describe('executePhase() via route()', () => {\n    it('should create a worktree for the deliver phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/worktree' }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree for the address_review phase', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix-review' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'phase_address_review' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'address_review',\n        expect.objectContaining({\n          WORKTREE_PATH: '/tmp/worktree',\n          PR_NUMBER: '50',\n          PR_BRANCH: 'feat/fix-review',\n        }),\n        task.id,\n      );\n    });\n\n    it('should create a worktree with agents-md prefix for compound phase', async () => {\n      const task = makeTask({ currentPhase: 'compound' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue(\n        '/tmp/compound-worktree',\n      );\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_compound' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'agents-md',\n        undefined,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'compound',\n        expect.objectContaining({ WORKTREE_PATH: '/tmp/compound-worktree' }),\n        task.id,\n      );\n    });\n\n    it('should pass rcBranch to getOrCreateWorktree when an active release exists', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue({\n        releaseId: 7,\n        key: 'RC-1',\n        title: 'Release 1',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v1.2',\n        baseBranch: 'master',\n      });\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        'rc/v1.2',\n      );\n    });\n\n    it('should pass undefined baseBranch to getOrCreateWorktree when no release is active', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      releaseService.findActiveForTask.mockResolvedValue(null);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.getOrCreateWorktree).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n        'feat',\n        undefined,\n      );\n    });\n\n    it('should not create a worktree for non-execute phases', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(worktreeService.getOrCreateWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should handle worktree creation failure with transient block', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockRejectedValue(\n        new Error('disk full'),\n      );\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(internalAdapter.handleSignal).toHaveBeenCalledWith(\n        task.id,\n        'deliver',\n        'BLOCKED:TRANSIENT',\n        expect.stringContaining('disk full'),\n      );\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should set phase status to active before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_plan' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'plan',\n        'active',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('should include PR env vars when task has prNumber and prBranch', async () => {\n      const task = makeTask({\n        prNumber: 99,\n        prBranch: 'feat/test',\n        directive: 'quick',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_review' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'review',\n        expect.objectContaining({\n          PR_NUMBER: '99',\n          PR_BRANCH: 'feat/test',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include TASK_DIR env var pointing to central path', async () => {\n      const centralPath =\n        '/home/agent/AgentSDE/tasks/production/AgentSDE/repo/gh-42';\n      artefacts.buildCentralTaskDir.mockReturnValue(centralPath);\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(artefacts.buildCentralTaskDir).toHaveBeenCalledWith(\n        'org/repo',\n        42,\n      );\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_DIR: centralPath }),\n        task.id,\n      );\n    });\n\n    it('should call onPhaseStart before invoking Claude', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      const callOrder: string[] = [];\n      hooks.onPhaseStart.mockImplementation((): Promise<void> => {\n        callOrder.push('onPhaseStart');\n        return Promise.resolve();\n      });\n      claude.invoke.mockImplementation((): Promise<void> => {\n        callOrder.push('claude');\n        return Promise.resolve();\n      });\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(hooks.onPhaseStart).toHaveBeenCalledWith(\n        expect.objectContaining({ issue: 42, repo: 'org/repo' }),\n        'refine',\n      );\n      expect(callOrder).toEqual(['onPhaseStart', 'claude']);\n    });\n\n    it('should not cleanup worktree when Claude invocation fails (cleanup happens at terminal state)', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockRejectedValue(new Error('Claude crash'));\n\n      await expect(\n        service.route(makeEvent({ type: 'phase_deliver' })),\n      ).rejects.toThrow('Claude crash');\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n\n    it('should not cleanup worktree after normal phase execution', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(worktreeService.cleanupWorktree).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('resolvePhase()', () => {\n    const task = makeTask();\n\n    it('should resolve issue_assigned to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_assigned' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve intake to refine', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'intake' }), task)).toBe(\n        'refine',\n      );\n    });\n\n    it('should resolve issue_comment to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'issue_comment' }), task),\n      ).toBe('refine');\n    });\n\n    it('should resolve issue_comment with prNumber to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'issue_comment', prNumber: 77 }),\n          task,\n        ),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve phase_* events to the phase name', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_refine' }), task),\n      ).toBe('refine');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_plan' }), task),\n      ).toBe('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_deliver' }), task),\n      ).toBe('deliver');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'phase_compound' }), task),\n      ).toBe('compound');\n    });\n\n    it('should resolve _completed events to the next phase', () => {\n      internalAdapter.getNextPhase.mockReturnValue('plan');\n      expect(\n        service.resolvePhase(makeEvent({ type: 'refine_completed' }), task),\n      ).toBe('plan');\n      expect(internalAdapter.getNextPhase).toHaveBeenCalledWith('refine');\n    });\n\n    it('should resolve pr_merged to compound', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'pr_merged' }), task)).toBe(\n        'compound',\n      );\n    });\n\n    it('should resolve review_approved to null (handled specially in route)', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'review_approved' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve review_changes_requested to address_review', () => {\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'review_changes_requested' }),\n          task,\n        ),\n      ).toBe('address_review');\n    });\n\n    it('should resolve ci_passed to deliver', () => {\n      expect(service.resolvePhase(makeEvent({ type: 'ci_passed' }), task)).toBe(\n        'deliver',\n      );\n    });\n\n    it('should resolve ci_failure to deliver', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'ci_failure' }), task),\n      ).toBe('deliver');\n    });\n\n    it('should resolve gate_released to the gated phase', () => {\n      const gatedTask = makeTask({ gatePhase: 'plan' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'gate_released' }), gatedTask),\n      ).toBe('plan');\n    });\n\n    it('should resolve pr_comment to reply_to_pr_comment', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'pr_comment' }), task),\n      ).toBe('reply_to_pr_comment');\n    });\n\n    it('should resolve task_cancelled to null', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_cancelled' }), task),\n      ).toBeNull();\n    });\n\n    it('should resolve task_reopened to refine', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'task_reopened' }), task),\n      ).toBe('refine');\n    });\n\n    it('should return null for unknown events', () => {\n      expect(\n        service.resolvePhase(makeEvent({ type: 'something_unknown' }), task),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already blocked', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'refine',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), blockedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already gated', () => {\n      const gatedTask = makeTask({\n        status: 'gated',\n        currentPhase: 'review',\n        gatePhase: 'review',\n      });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), gatedTask),\n      ).toBeNull();\n    });\n\n    it('should ignore intake when task is already past refine', () => {\n      const planTask = makeTask({ currentPhase: 'plan', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), planTask),\n      ).toBeNull();\n    });\n\n    it('should allow intake when task is still at refine', () => {\n      const refineTask = makeTask({ currentPhase: 'refine', status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'intake' }), refineTask),\n      ).toBe('refine');\n    });\n\n    it('should resolve directive to gated phase for gated tasks', () => {\n      const gatedTask = makeTask({ status: 'gated', gatePhase: 'review' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), gatedTask),\n      ).toBe('review');\n    });\n\n    it('should return null for directive on non-gated tasks', () => {\n      const activeTask = makeTask({ status: 'active' });\n      expect(\n        service.resolvePhase(makeEvent({ type: 'directive' }), activeTask),\n      ).toBeNull();\n    });\n\n    it('should resolve retry directive to currentPhase', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          stuckTask,\n        ),\n      ).toBe('deliver');\n    });\n\n    it('should resolve retry directive from event.directive field', () => {\n      const stuckTask = makeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'retry' }),\n          stuckTask,\n        ),\n      ).toBe('plan');\n    });\n\n    it('should resolve restart directive to refine regardless of current phase', () => {\n      const blockedTask = makeTask({\n        status: 'blocked',\n        currentPhase: 'deliver',\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n          blockedTask,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve restart directive from event.directive field', () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'restart' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via commentBody', () => {\n      const task = makeTask({ status: 'blocked', currentPhase: 'deliver' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should resolve replan directive to refine via event.directive field', () => {\n      const task = makeTask({ status: 'active', currentPhase: 'plan' });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', directive: 'replan' }),\n          task,\n        ),\n      ).toBe('refine');\n    });\n\n    it('should return null for retry on task with no currentPhase', () => {\n      const taskNoPhase = makeTask({\n        status: 'active',\n        currentPhase: null as unknown as string,\n      });\n      expect(\n        service.resolvePhase(\n          makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n          taskNoPhase,\n        ),\n      ).toBeNull();\n    });\n  });\n\n  // isGated() removed — gate logic moved to advanceAndEnqueue() in internal-adapter.\n  // Gate tests are now in internal-adapter.service.spec.ts.\n\n  // signal mapping tests removed — result handling moved to InvocationResultListener.\n\n  describe('issue_comment routing', () => {\n    it('should route issue_comment events to the refine phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_comment' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('should route issue_comment with prNumber to reply_to_pr_comment with COMMENT_ID in env', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PHASE: 'reply_to_pr_comment',\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should include PR_NUMBER from event when task has no prNumber', async () => {\n      const task = makeTask({ prNumber: null });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'issue_comment', prNumber: 77, commentId: 99999 }),\n      );\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'reply_to_pr_comment',\n        expect.objectContaining({\n          PR_NUMBER: '77',\n          COMMENT_ID: '99999',\n        }),\n        task.id,\n      );\n    });\n\n    it('should not include AGENT_PERSONA in env for any phase', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('AGENT_PERSONA');\n    });\n  });\n\n  describe('review phase status on external review events', () => {\n    it('marks review phase complete when review_approved routes to deliver', async () => {\n      // Use quick directive so deliver is not gated and Claude gets invoked\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n      expect(taskState.save).toHaveBeenCalled();\n    });\n\n    it('marks review phase complete when review_changes_requested routes to address_review', async () => {\n      const task = makeTask({ prNumber: 50, prBranch: 'feat/fix' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('pending') // review status check (new code)\n        .mockReturnValueOnce('pending'); // address_review phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(makeEvent({ type: 'review_changes_requested' }));\n\n      expect(taskState.setPhaseStatus).toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n\n    it('skips review phase status update when review is already complete', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        prNumber: 50,\n        prBranch: 'feat/fix',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus\n        .mockReturnValueOnce('complete') // review status — already complete\n        .mockReturnValueOnce('pending'); // deliver phase guard\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'review_approved' }));\n\n      // setPhaseStatus should NOT be called for review since it's already complete\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalledWith(\n        task,\n        'review',\n        'complete',\n      );\n    });\n  });\n\n  describe('event recording', () => {\n    it('should record phase_started event', async () => {\n      const task = makeTask();\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'phase_started',\n        'phase-router',\n        { phase: 'refine' },\n      );\n    });\n\n    // phase_gated events are now recorded by internal-adapter (advanceAndEnqueue)\n    // after the phase completes, not by phase-router before execution.\n  });\n\n  describe('retry/restart directive handling', () => {\n    it('retry on stuck task: resets state and executes current phase', async () => {\n      const task = makeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n        phaseDeliver: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n      worktreeService.getOrCreateWorktree.mockResolvedValue('/tmp/worktree');\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ PHASE: 'deliver' }),\n        task.id,\n      );\n    });\n\n    it('retry on blocked task: resets state and executes', async () => {\n      const task = makeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'directive', directive: 'retry' }));\n\n      expect(taskState.resetForRetry).toHaveBeenCalledWith(task);\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'plan',\n        expect.objectContaining({ PHASE: 'plan' }),\n        task.id,\n      );\n    });\n\n    it('retry on succeeded task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'succeeded', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent retry',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Retry rejected'),\n      );\n    });\n\n    it('retry on failed task: rejects with feedback comment', async () => {\n      const task = makeTask({ status: 'failed', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent retry' }),\n      );\n\n      expect(taskState.resetForRetry).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on non-active task: resets all phases and executes refine', async () => {\n      const task = makeTask({\n        status: 'failed',\n        currentPhase: 'deliver',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(\n        makeEvent({ type: 'directive', commentBody: '/agent restart' }),\n      );\n\n      expect(taskState.resetForRestart).toHaveBeenCalledWith(task);\n      expect(taskState.save).toHaveBeenCalled();\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ PHASE: 'refine' }),\n        task.id,\n      );\n    });\n\n    it('restart on active task: rejects with feedback comment, no dispatch', async () => {\n      const task = makeTask({ status: 'active', currentPhase: 'deliver' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent restart',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(taskState.resetForRestart).not.toHaveBeenCalled();\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('Restart rejected'),\n      );\n    });\n\n    it('unknown directive: posts feedback comment listing valid directives', async () => {\n      const task = makeTask({ status: 'active' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent foobar',\n          repo: 'org/repo',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n      expect(gitHub.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('retry'),\n      );\n    });\n  });\n\n  describe('quality gate override', () => {\n    it('passes QUALITY_GATE_OVERRIDE=1 env var when task has qualityGateOverride=true for deliver phase', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'deliver',\n        expect.objectContaining({ QUALITY_GATE_OVERRIDE: '1' }),\n        task.id,\n      );\n    });\n\n    it('does not pass QUALITY_GATE_OVERRIDE when qualityGateOverride is false', async () => {\n      const task = makeTask({ directive: 'quick', qualityGateOverride: false });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      const envArg = claude.invoke.mock.calls[0][1];\n      expect(envArg).not.toHaveProperty('QUALITY_GATE_OVERRIDE');\n    });\n\n    it('resets qualityGateOverride to false when deliver phase starts with override set', async () => {\n      const task = makeTask({\n        directive: 'quick',\n        qualityGateOverride: true,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(taskState.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        false,\n      );\n    });\n  });\n\n  // compound scope validation tests removed — result handling moved to InvocationResultListener.\n\n  describe('idempotency guards', () => {\n    it('should ignore intake event when task is already past refine phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'active',\n        phasePlan: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is in deliver phase (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'deliver',\n        status: 'active',\n        phaseDeliver: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is blocked (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'blocked',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should ignore intake event when task is gated (duplicate intake guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'plan',\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'Awaiting approval',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'execute',\n        status: 'active',\n        gatePhase: null,\n        gateReason: null,\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(\n        makeEvent({\n          type: 'directive',\n          commentBody: '/agent approve',\n        }),\n      );\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.setPhaseStatus).not.toHaveBeenCalled();\n    });\n\n    // artefact sync tests removed — artefact handling moved to InvocationResultListener.\n\n    it('should ignore intake event when task is already complete (late duplicate guard)', async () => {\n      const task = makeTask({\n        currentPhase: 'compound',\n        status: 'complete',\n        phaseCompound: 'complete',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.route(makeEvent({ type: 'intake' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n\n      expect(taskState.createTask).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    it('calls createTask once and invoke once when route() is called twice with same event', async () => {\n      const task = makeTask();\n      // First call: no task exists → create\n      // Second call: task now exists → reuse\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValue(task);\n      taskState.createTask.mockResolvedValue(task);\n      taskState.save.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      const event = makeEvent({ type: 'issue_assigned' });\n      await service.route(event);\n      await service.route(event);\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Second call: phase is still 'pending' so invoke runs again (not prevented by active guard),\n      // but the idempotency contract for external events allows this. The important guarantee is\n      // createTask is not called twice.\n      expect(claude.invoke).toHaveBeenCalledTimes(2);\n    });\n\n    it('falls back to existing task when createTask throws unique-constraint error', async () => {\n      const existingTask = makeTask({ id: 5 });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null) // first call: no task\n        .mockResolvedValue(existingTask); // fallback + subsequent calls (paused check etc.)\n      taskState.createTask.mockRejectedValueOnce(\n        Object.assign(new Error('UNIQUE constraint failed'), {\n          code: 'SQLITE_CONSTRAINT',\n        }),\n      );\n      taskState.save.mockResolvedValue(existingTask);\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'issue_assigned' }));\n\n      expect(taskState.createTask).toHaveBeenCalledTimes(1);\n      // Falls back to the existing task and continues execution\n      expect(claude.invoke).toHaveBeenCalledWith(\n        'refine',\n        expect.objectContaining({ TASK_ID: String(existingTask.id) }),\n        existingTask.id,\n      );\n    });\n\n    it('re-throws createTask error when no existing task can be found', async () => {\n      const error = Object.assign(new Error('DB error'), {\n        code: 'SOME_OTHER_ERROR',\n      });\n      taskState.findByIssueAndRepo\n        .mockResolvedValueOnce(null)\n        .mockResolvedValueOnce(null); // fallback also finds nothing\n      taskState.createTask.mockRejectedValueOnce(error);\n\n      await expect(\n        service.route(makeEvent({ type: 'issue_assigned' })),\n      ).rejects.toThrow('DB error');\n    });\n\n    it('skips phase_ event re-execution when phase is failed and task is gated (BLOCKED:PERSISTENT retry)', async () => {\n      const task = makeTask({\n        status: 'gated',\n        gatePhase: 'deliver',\n        gateReason: 'Max retries exceeded',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n\n      // BullMQ stall-retry re-delivers the phase_deliver job\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('allows phase_ re-execution when phase is failed but task is NOT gated (transient retry)', async () => {\n      // InternalAdapterService sets phase to 'failed' and re-enqueues on transient block;\n      // task.status stays 'active'. This legitimate retry must proceed.\n      const task = makeTask({\n        status: 'active',\n        phaseDeliver: 'failed',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      taskState.getPhaseStatus.mockReturnValue('failed');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_deliver' }));\n\n      expect(claude.invoke).toHaveBeenCalled();\n    });\n\n    it('skips executePhase() re-entry when phase is already active (defensive guard)', async () => {\n      // Phase status is 'active' in-memory on the task passed into executePhase.\n      // This simulates a scenario where route()'s active-phase check was bypassed.\n      const task = makeTask({\n        phaseRefine: 'active',\n      });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // Return 'active' from getPhaseStatus (for both route() check and executePhase() guard)\n      taskState.getPhaseStatus.mockReturnValue('active');\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).not.toHaveBeenCalled();\n    });\n\n    it('calls invoke exactly once when route() processes the same event while phase is active', async () => {\n      const task = makeTask({ phaseRefine: 'pending' });\n      taskState.findByIssueAndRepo.mockResolvedValue(task);\n      // First getPhaseStatus call (route guard): 'pending' → proceed\n      // executePhase() defensive guard also returns 'pending' → proceed to invoke\n      taskState.getPhaseStatus.mockReturnValue('pending');\n      taskState.save.mockResolvedValue(task);\n      claude.invoke.mockResolvedValue(undefined);\n      hooks.onPhaseComplete.mockResolvedValue(undefined);\n\n      await service.route(makeEvent({ type: 'phase_refine' }));\n\n      expect(claude.invoke).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n","structuredPatch":[{"oldStart":1436,"oldLines":7,"newStart":1436,"newLines":7,"lines":[" ","     it('should no-op when approve directive arrives on already-active task (duplicate approve guard)', async () => {","       const task = makeTask({","-        currentPhase: 'execute',","+        currentPhase: 'deliver',","         status: 'active',","         gatePhase: null,","         gateReason: null,"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01WEAXk9NjnHt5WJLPpS2xJZ","type":"message","role":"assistant","content":[{"type":"text","text":"Now update directive.service.spec.ts:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":313,"cache_read_input_tokens":102599,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":313},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"333fb65a-e35e-4942-9892-78e9cb0be628"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01WEAXk9NjnHt5WJLPpS2xJZ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014dYpzhFKhddEfXu3hmppFY","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/directive/directive.service.spec.ts","old_string":"  task.phaseRefine = 'pending';\n  task.phasePlan = 'pending';\n  task.phaseExecute = 'pending';\n  task.phaseReview = 'pending';","new_string":"  task.phaseRefine = 'pending';\n  task.phasePlan = 'pending';\n  task.phaseReview = 'pending';"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":313,"cache_read_input_tokens":102599,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":313},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"a7f41297-7553-466d-937b-8c6d42fa0ccb"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014dYpzhFKhddEfXu3hmppFY","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/directive/directive.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"ac4ded2f-2ab8-441c-a2c2-792cf58bebe8","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/directive/directive.service.spec.ts","oldString":"  task.phaseRefine = 'pending';\n  task.phasePlan = 'pending';\n  task.phaseExecute = 'pending';\n  task.phaseReview = 'pending';","newString":"  task.phaseRefine = 'pending';\n  task.phasePlan = 'pending';\n  task.phaseReview = 'pending';","originalFile":"import { Test, TestingModule } from '@nestjs/testing';\nimport { DirectiveService } from './directive.service';\nimport { TaskStateService } from '../task-state';\nimport { DispatchService } from '../dispatch';\nimport { InternalAdapterService } from '../internal-adapter';\nimport {\n  PLATFORM_PROVIDER,\n  PlatformProvider,\n} from '../platform/platform-provider.interface';\nimport { EventService } from '../event/event.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { TaskEntity } from '../database/entities';\n\nfunction buildFakeTask(overrides?: Partial<TaskEntity>): TaskEntity {\n  const task = new TaskEntity();\n  task.id = 1;\n  task.issue = 18;\n  task.repo = 'AgentSDE/agent-core';\n  task.directive = 'auto';\n  task.currentPhase = 'refine';\n  task.status = 'active';\n  task.gatePhase = null;\n  task.gateReason = null;\n  task.prNumber = null;\n  task.prBranch = null;\n  task.phaseRefine = 'pending';\n  task.phasePlan = 'pending';\n  task.phaseExecute = 'pending';\n  task.phaseReview = 'pending';\n  task.phaseDeliver = 'pending';\n  task.phaseCompound = 'pending';\n  task.qualityGateOverride = false;\n  task.replanAttempt = 0;\n  task.createdAt = new Date();\n  task.updatedAt = new Date();\n  Object.assign(task, overrides);\n  return task;\n}\n\ndescribe('DirectiveService', () => {\n  let service: DirectiveService;\n  let taskStateService: jest.Mocked<\n    Pick<\n      TaskStateService,\n      | 'findByIssueAndRepo'\n      | 'save'\n      | 'clearGate'\n      | 'resetForRetry'\n      | 'resetForRestart'\n      | 'resetForReplan'\n      | 'setQualityGateOverride'\n    >\n  >;\n  let dispatchService: jest.Mocked<Pick<DispatchService, 'dispatch'>>;\n  let internalAdapterService: jest.Mocked<\n    Pick<InternalAdapterService, 'enqueuePhase'>\n  >;\n  let platformProvider: jest.Mocked<\n    Pick<PlatformProvider, 'closePR' | 'deleteBranch' | 'postComment'>\n  >;\n  let eventService: jest.Mocked<Pick<EventService, 'record'>>;\n  let phaseHooksService: jest.Mocked<\n    Pick<PhaseHooksService, 'cleanupLabelsForReplan'>\n  >;\n  let artefactsService: jest.Mocked<\n    Pick<ArtefactsService, 'clearPlanArtefacts' | 'buildCentralTaskDir'>\n  >;\n\n  beforeEach(async () => {\n    taskStateService = {\n      findByIssueAndRepo: jest.fn().mockResolvedValue(buildFakeTask()),\n      save: jest.fn().mockImplementation((t: TaskEntity) => Promise.resolve(t)),\n      clearGate: jest.fn(),\n      resetForRetry: jest.fn(),\n      resetForRestart: jest.fn(),\n      resetForReplan: jest.fn(),\n      setQualityGateOverride: jest.fn(),\n    };\n\n    artefactsService = {\n      clearPlanArtefacts: jest.fn().mockResolvedValue(undefined),\n      buildCentralTaskDir: jest\n        .fn()\n        .mockReturnValue('/tmp/tasks/AgentSDE/agent-core/gh-18'),\n    };\n\n    dispatchService = {\n      dispatch: jest.fn().mockResolvedValue(null),\n    };\n\n    internalAdapterService = {\n      enqueuePhase: jest.fn().mockResolvedValue(undefined),\n    };\n\n    platformProvider = {\n      closePR: jest.fn().mockResolvedValue(undefined),\n      deleteBranch: jest.fn().mockResolvedValue(undefined),\n      postComment: jest.fn().mockResolvedValue(undefined),\n    };\n\n    eventService = {\n      record: jest.fn().mockResolvedValue(undefined),\n    };\n\n    phaseHooksService = {\n      cleanupLabelsForReplan: jest.fn().mockResolvedValue(undefined),\n    };\n\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        DirectiveService,\n        { provide: TaskStateService, useValue: taskStateService },\n        { provide: DispatchService, useValue: dispatchService },\n        { provide: InternalAdapterService, useValue: internalAdapterService },\n        { provide: PLATFORM_PROVIDER, useValue: platformProvider },\n        { provide: EventService, useValue: eventService },\n        { provide: PhaseHooksService, useValue: phaseHooksService },\n        { provide: ArtefactsService, useValue: artefactsService },\n      ],\n    }).compile();\n\n    service = module.get<DirectiveService>(DirectiveService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('auto: sets directive and dispatches', async () => {\n    await service.applyDirective(\n      18,\n      'auto',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.findByIssueAndRepo).toHaveBeenCalledWith(\n      18,\n      'AgentSDE/agent-core',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n    expect(taskStateService.clearGate).not.toHaveBeenCalled();\n  });\n\n  it('quick: sets directive and dispatches', async () => {\n    await service.applyDirective(\n      18,\n      'quick',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n  });\n\n  it('careful: sets directive and dispatches', async () => {\n    await service.applyDirective(\n      18,\n      'careful',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n  });\n\n  describe('refine directive', () => {\n    it('refine on gated task: clears gate, enqueues refine phase, posts confirmation', async () => {\n      const gatedTask = buildFakeTask({\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'awaiting approval',\n        directive: 'careful',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).toHaveBeenCalledWith(gatedTask);\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'refine directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('refine on active task: posts rejection comment, no dispatch/enqueue', async () => {\n      const activeTask = buildFakeTask({ status: 'active' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(activeTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('refine on succeeded task: posts terminal rejection comment, no dispatch/enqueue', async () => {\n      const succeededTask = buildFakeTask({ status: 'succeeded' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(succeededTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('refine on failed task: posts terminal rejection comment, no dispatch/enqueue', async () => {\n      const failedTask = buildFakeTask({ status: 'failed' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n    });\n\n    it('refine does not overwrite task.directive', async () => {\n      const gatedTask = buildFakeTask({\n        status: 'gated',\n        gatePhase: 'plan',\n        directive: 'careful',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(gatedTask.directive).toBe('careful');\n    });\n  });\n\n  it('approve: dispatches directive without pre-clearing gate (gate cleared by phase-router)', async () => {\n    const gatedTask = buildFakeTask({\n      gatePhase: 'review',\n      gateReason: 'needs approval',\n      status: 'gated',\n    });\n    taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n\n    await service.applyDirective(\n      18,\n      'approve',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    // Gate must NOT be cleared here — phase-router reads gatePhase in resolvePhase\n    // to determine which phase to resume. Pre-clearing leaves gatePhase=null and\n    // the router returns null, leaving the task stuck at status=gated.\n    expect(taskStateService.clearGate).not.toHaveBeenCalled();\n    expect(gatedTask.gatePhase).toBe('review'); // preserved for phase-router\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n  });\n\n  it('stop: sets status=paused and does NOT dispatch', async () => {\n    await service.applyDirective(\n      18,\n      'stop',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).not.toHaveBeenCalled();\n  });\n\n  it('posts a GitHub confirmation comment for all directives', async () => {\n    await service.applyDirective(\n      18,\n      'auto',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(platformProvider.postComment).toHaveBeenCalledWith(\n      'AgentSDE',\n      'agent-core',\n      18,\n      expect.any(String),\n    );\n  });\n\n  it('returns early if no task found', async () => {\n    taskStateService.findByIssueAndRepo.mockResolvedValue(null);\n    await service.applyDirective(\n      18,\n      'auto',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(dispatchService.dispatch).not.toHaveBeenCalled();\n  });\n\n  describe('retry directive', () => {\n    it('retry on stuck task: resets phase, enqueues phase, posts confirmation', async () => {\n      const stuckTask = buildFakeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(stuckTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).toHaveBeenCalledWith(stuckTask);\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'execute',\n        'retry directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalled();\n    });\n\n    it('retry on blocked task: resets phase, enqueues phase', async () => {\n      const blockedTask = buildFakeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(blockedTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).toHaveBeenCalledWith(blockedTask);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'plan',\n        'retry directive',\n      );\n    });\n\n    it('retry on succeeded task: posts rejection comment, does not dispatch', async () => {\n      const succeededTask = buildFakeTask({ status: 'succeeded' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(succeededTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      // rejection comment posted\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('retry on failed task: posts rejection comment, does not dispatch', async () => {\n      const failedTask = buildFakeTask({ status: 'failed' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n    });\n\n    it('retry does not overwrite task.directive', async () => {\n      const task = buildFakeTask({ directive: 'careful', status: 'stuck' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(task.directive).toBe('careful');\n    });\n  });\n\n  describe('skip-quality-gate directive', () => {\n    it('sets qualityGateOverride=true on the task and posts confirmation', async () => {\n      const task = buildFakeTask({ status: 'gated', currentPhase: 'deliver' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'skip-quality-gate',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        true,\n      );\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('does not dispatch a directive event', async () => {\n      await service.applyDirective(\n        18,\n        'skip-quality-gate',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('restart directive', () => {\n    it('restart on non-active task: resets all phases, clears PR state, enqueues refine, posts confirmation', async () => {\n      const failedTask = buildFakeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n        prNumber: null,\n        prBranch: null,\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(failedTask);\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on stuck task: resets all phases, enqueues refine', async () => {\n      const stuckTask = buildFakeTask({ status: 'stuck', prNumber: null });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(stuckTask);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(stuckTask);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n    });\n\n    it('restart with open PR: posts superseded comment, closes PR, clears prNumber/prBranch', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 77,\n        prBranch: 'feat/77-feature',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      // First postComment call should be the superseded comment on the PR\n      expect(platformProvider.postComment).toHaveBeenNthCalledWith(\n        1,\n        'AgentSDE',\n        'agent-core',\n        77,\n        expect.any(String),\n      );\n      expect(platformProvider.closePR).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        77,\n      );\n      expect(platformProvider.deleteBranch).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        'feat/77-feature',\n      );\n      expect(task.prNumber).toBeNull();\n      expect(task.prBranch).toBeNull();\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n    });\n\n    it('restart: PR close failure logs warning and proceeds', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 88,\n        prBranch: 'feat/88-thing',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n      (platformProvider.closePR as jest.Mock).mockRejectedValueOnce(\n        new Error('GitHub API error: 500'),\n      );\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n    });\n\n    it('restart on active task: posts rejection comment, does not dispatch', async () => {\n      const activeTask = buildFakeTask({ status: 'active' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(activeTask);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      // rejection comment posted\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('restart does not overwrite task.directive', async () => {\n      const task = buildFakeTask({ directive: 'careful', status: 'blocked' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(task.directive).toBe('careful');\n    });\n  });\n\n  describe('replan directive', () => {\n    it('normal replan: uses resetForReplan, closes PR, deletes branch, clears artefacts, enqueues refine', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n        prNumber: 42,\n        prBranch: 'feat/42-my-feature',\n        replanAttempt: 0,\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n      (taskStateService.resetForReplan as jest.Mock).mockImplementation(\n        (t: TaskEntity): void => {\n          t.prNumber = null;\n          t.prBranch = null;\n          t.replanAttempt = (t.replanAttempt ?? 0) + 1;\n        },\n      );\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n      expect(taskStateService.resetForRestart).not.toHaveBeenCalled();\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(platformProvider.closePR).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        42,\n      );\n      expect(platformProvider.deleteBranch).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        'feat/42-my-feature',\n      );\n      expect(artefactsService.clearPlanArtefacts).toHaveBeenCalledWith(\n        '/tmp/tasks/AgentSDE/agent-core/gh-18',\n      );\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'replan_triggered',\n        'directive-service',\n        expect.objectContaining({ actor: 'zhjarvis', replan_attempt: 1 }),\n      );\n      expect(phaseHooksService.cleanupLabelsForReplan).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n      );\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'replan directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalled();\n    });\n\n    it('replan with no PR: skips PR close/branch delete, still clears artefacts', async () => {\n      const task = buildFakeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n        prNumber: null,\n        prBranch: null,\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(platformProvider.closePR).not.toHaveBeenCalled();\n      expect(platformProvider.deleteBranch).not.toHaveBeenCalled();\n      expect(artefactsService.clearPlanArtefacts).toHaveBeenCalled();\n      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'replan directive',\n      );\n    });\n\n    it('replan already in active refine: posts no-op comment, does not enqueue', async () => {\n      const task = buildFakeTask({\n        status: 'active',\n        currentPhase: 'refine',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('replan on succeeded task: posts rejection comment, does not enqueue', async () => {\n      const task = buildFakeTask({ status: 'succeeded' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('replan on failed task: posts rejection comment, does not enqueue', async () => {\n      const task = buildFakeTask({ status: 'failed' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n    });\n\n    it('replan: PR close failure logs warning and proceeds', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 99,\n        prBranch: 'feat/99-branch',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n      (platformProvider.closePR as jest.Mock).mockRejectedValueOnce(\n        new Error('GitHub API error: 500 — Internal Server Error'),\n      );\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      // Should still reset and enqueue even when PR close fails\n      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'replan directive',\n      );\n    });\n\n    it('replan: posts superseded comment on open PR before closing', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 55,\n        prBranch: 'feat/55-thing',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      // First postComment call should be the superseded comment on the PR\n      expect(platformProvider.postComment).toHaveBeenNthCalledWith(\n        1,\n        'AgentSDE',\n        'agent-core',\n        55,\n        expect.any(String),\n      );\n      expect(platformProvider.closePR).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        55,\n      );\n    });\n  });\n});\n","structuredPatch":[{"oldStart":26,"oldLines":7,"newStart":26,"newLines":6,"lines":["   task.prBranch = null;","   task.phaseRefine = 'pending';","   task.phasePlan = 'pending';","-  task.phaseExecute = 'pending';","   task.phaseReview = 'pending';","   task.phaseDeliver = 'pending';","   task.phaseCompound = 'pending';"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01ARuqKk7LAvWyV2rhZgMpwi","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013mgai8uwpmF5fCxbBEeWPi","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/directive/directive.service.spec.ts","old_string":"    it('retry on stuck task: resets phase, enqueues phase, posts confirmation', async () => {\n      const stuckTask = buildFakeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });","new_string":"    it('retry on stuck task: resets phase, enqueues phase, posts confirmation', async () => {\n      const stuckTask = buildFakeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n        phaseDeliver: 'active',\n      });"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":407,"cache_read_input_tokens":102912,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":407},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"feeb4ef5-ec07-4ee1-b5dc-5f19e321fc1f"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013mgai8uwpmF5fCxbBEeWPi","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/directive/directive.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"ee44ab3e-3e52-438e-877f-3d6a9d431be5","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/directive/directive.service.spec.ts","oldString":"    it('retry on stuck task: resets phase, enqueues phase, posts confirmation', async () => {\n      const stuckTask = buildFakeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });","newString":"    it('retry on stuck task: resets phase, enqueues phase, posts confirmation', async () => {\n      const stuckTask = buildFakeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n        phaseDeliver: 'active',\n      });","originalFile":"import { Test, TestingModule } from '@nestjs/testing';\nimport { DirectiveService } from './directive.service';\nimport { TaskStateService } from '../task-state';\nimport { DispatchService } from '../dispatch';\nimport { InternalAdapterService } from '../internal-adapter';\nimport {\n  PLATFORM_PROVIDER,\n  PlatformProvider,\n} from '../platform/platform-provider.interface';\nimport { EventService } from '../event/event.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { TaskEntity } from '../database/entities';\n\nfunction buildFakeTask(overrides?: Partial<TaskEntity>): TaskEntity {\n  const task = new TaskEntity();\n  task.id = 1;\n  task.issue = 18;\n  task.repo = 'AgentSDE/agent-core';\n  task.directive = 'auto';\n  task.currentPhase = 'refine';\n  task.status = 'active';\n  task.gatePhase = null;\n  task.gateReason = null;\n  task.prNumber = null;\n  task.prBranch = null;\n  task.phaseRefine = 'pending';\n  task.phasePlan = 'pending';\n  task.phaseReview = 'pending';\n  task.phaseDeliver = 'pending';\n  task.phaseCompound = 'pending';\n  task.qualityGateOverride = false;\n  task.replanAttempt = 0;\n  task.createdAt = new Date();\n  task.updatedAt = new Date();\n  Object.assign(task, overrides);\n  return task;\n}\n\ndescribe('DirectiveService', () => {\n  let service: DirectiveService;\n  let taskStateService: jest.Mocked<\n    Pick<\n      TaskStateService,\n      | 'findByIssueAndRepo'\n      | 'save'\n      | 'clearGate'\n      | 'resetForRetry'\n      | 'resetForRestart'\n      | 'resetForReplan'\n      | 'setQualityGateOverride'\n    >\n  >;\n  let dispatchService: jest.Mocked<Pick<DispatchService, 'dispatch'>>;\n  let internalAdapterService: jest.Mocked<\n    Pick<InternalAdapterService, 'enqueuePhase'>\n  >;\n  let platformProvider: jest.Mocked<\n    Pick<PlatformProvider, 'closePR' | 'deleteBranch' | 'postComment'>\n  >;\n  let eventService: jest.Mocked<Pick<EventService, 'record'>>;\n  let phaseHooksService: jest.Mocked<\n    Pick<PhaseHooksService, 'cleanupLabelsForReplan'>\n  >;\n  let artefactsService: jest.Mocked<\n    Pick<ArtefactsService, 'clearPlanArtefacts' | 'buildCentralTaskDir'>\n  >;\n\n  beforeEach(async () => {\n    taskStateService = {\n      findByIssueAndRepo: jest.fn().mockResolvedValue(buildFakeTask()),\n      save: jest.fn().mockImplementation((t: TaskEntity) => Promise.resolve(t)),\n      clearGate: jest.fn(),\n      resetForRetry: jest.fn(),\n      resetForRestart: jest.fn(),\n      resetForReplan: jest.fn(),\n      setQualityGateOverride: jest.fn(),\n    };\n\n    artefactsService = {\n      clearPlanArtefacts: jest.fn().mockResolvedValue(undefined),\n      buildCentralTaskDir: jest\n        .fn()\n        .mockReturnValue('/tmp/tasks/AgentSDE/agent-core/gh-18'),\n    };\n\n    dispatchService = {\n      dispatch: jest.fn().mockResolvedValue(null),\n    };\n\n    internalAdapterService = {\n      enqueuePhase: jest.fn().mockResolvedValue(undefined),\n    };\n\n    platformProvider = {\n      closePR: jest.fn().mockResolvedValue(undefined),\n      deleteBranch: jest.fn().mockResolvedValue(undefined),\n      postComment: jest.fn().mockResolvedValue(undefined),\n    };\n\n    eventService = {\n      record: jest.fn().mockResolvedValue(undefined),\n    };\n\n    phaseHooksService = {\n      cleanupLabelsForReplan: jest.fn().mockResolvedValue(undefined),\n    };\n\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        DirectiveService,\n        { provide: TaskStateService, useValue: taskStateService },\n        { provide: DispatchService, useValue: dispatchService },\n        { provide: InternalAdapterService, useValue: internalAdapterService },\n        { provide: PLATFORM_PROVIDER, useValue: platformProvider },\n        { provide: EventService, useValue: eventService },\n        { provide: PhaseHooksService, useValue: phaseHooksService },\n        { provide: ArtefactsService, useValue: artefactsService },\n      ],\n    }).compile();\n\n    service = module.get<DirectiveService>(DirectiveService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('auto: sets directive and dispatches', async () => {\n    await service.applyDirective(\n      18,\n      'auto',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.findByIssueAndRepo).toHaveBeenCalledWith(\n      18,\n      'AgentSDE/agent-core',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n    expect(taskStateService.clearGate).not.toHaveBeenCalled();\n  });\n\n  it('quick: sets directive and dispatches', async () => {\n    await service.applyDirective(\n      18,\n      'quick',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n  });\n\n  it('careful: sets directive and dispatches', async () => {\n    await service.applyDirective(\n      18,\n      'careful',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n  });\n\n  describe('refine directive', () => {\n    it('refine on gated task: clears gate, enqueues refine phase, posts confirmation', async () => {\n      const gatedTask = buildFakeTask({\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'awaiting approval',\n        directive: 'careful',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).toHaveBeenCalledWith(gatedTask);\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'refine directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('refine on active task: posts rejection comment, no dispatch/enqueue', async () => {\n      const activeTask = buildFakeTask({ status: 'active' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(activeTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('refine on succeeded task: posts terminal rejection comment, no dispatch/enqueue', async () => {\n      const succeededTask = buildFakeTask({ status: 'succeeded' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(succeededTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('refine on failed task: posts terminal rejection comment, no dispatch/enqueue', async () => {\n      const failedTask = buildFakeTask({ status: 'failed' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n    });\n\n    it('refine does not overwrite task.directive', async () => {\n      const gatedTask = buildFakeTask({\n        status: 'gated',\n        gatePhase: 'plan',\n        directive: 'careful',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(gatedTask.directive).toBe('careful');\n    });\n  });\n\n  it('approve: dispatches directive without pre-clearing gate (gate cleared by phase-router)', async () => {\n    const gatedTask = buildFakeTask({\n      gatePhase: 'review',\n      gateReason: 'needs approval',\n      status: 'gated',\n    });\n    taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n\n    await service.applyDirective(\n      18,\n      'approve',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    // Gate must NOT be cleared here — phase-router reads gatePhase in resolvePhase\n    // to determine which phase to resume. Pre-clearing leaves gatePhase=null and\n    // the router returns null, leaving the task stuck at status=gated.\n    expect(taskStateService.clearGate).not.toHaveBeenCalled();\n    expect(gatedTask.gatePhase).toBe('review'); // preserved for phase-router\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n  });\n\n  it('stop: sets status=paused and does NOT dispatch', async () => {\n    await service.applyDirective(\n      18,\n      'stop',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).not.toHaveBeenCalled();\n  });\n\n  it('posts a GitHub confirmation comment for all directives', async () => {\n    await service.applyDirective(\n      18,\n      'auto',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(platformProvider.postComment).toHaveBeenCalledWith(\n      'AgentSDE',\n      'agent-core',\n      18,\n      expect.any(String),\n    );\n  });\n\n  it('returns early if no task found', async () => {\n    taskStateService.findByIssueAndRepo.mockResolvedValue(null);\n    await service.applyDirective(\n      18,\n      'auto',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(dispatchService.dispatch).not.toHaveBeenCalled();\n  });\n\n  describe('retry directive', () => {\n    it('retry on stuck task: resets phase, enqueues phase, posts confirmation', async () => {\n      const stuckTask = buildFakeTask({\n        status: 'stuck',\n        currentPhase: 'execute',\n        phaseExecute: 'active',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(stuckTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).toHaveBeenCalledWith(stuckTask);\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'execute',\n        'retry directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalled();\n    });\n\n    it('retry on blocked task: resets phase, enqueues phase', async () => {\n      const blockedTask = buildFakeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(blockedTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).toHaveBeenCalledWith(blockedTask);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'plan',\n        'retry directive',\n      );\n    });\n\n    it('retry on succeeded task: posts rejection comment, does not dispatch', async () => {\n      const succeededTask = buildFakeTask({ status: 'succeeded' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(succeededTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      // rejection comment posted\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('retry on failed task: posts rejection comment, does not dispatch', async () => {\n      const failedTask = buildFakeTask({ status: 'failed' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n    });\n\n    it('retry does not overwrite task.directive', async () => {\n      const task = buildFakeTask({ directive: 'careful', status: 'stuck' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(task.directive).toBe('careful');\n    });\n  });\n\n  describe('skip-quality-gate directive', () => {\n    it('sets qualityGateOverride=true on the task and posts confirmation', async () => {\n      const task = buildFakeTask({ status: 'gated', currentPhase: 'deliver' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'skip-quality-gate',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        true,\n      );\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('does not dispatch a directive event', async () => {\n      await service.applyDirective(\n        18,\n        'skip-quality-gate',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('restart directive', () => {\n    it('restart on non-active task: resets all phases, clears PR state, enqueues refine, posts confirmation', async () => {\n      const failedTask = buildFakeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n        prNumber: null,\n        prBranch: null,\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(failedTask);\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on stuck task: resets all phases, enqueues refine', async () => {\n      const stuckTask = buildFakeTask({ status: 'stuck', prNumber: null });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(stuckTask);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(stuckTask);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n    });\n\n    it('restart with open PR: posts superseded comment, closes PR, clears prNumber/prBranch', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 77,\n        prBranch: 'feat/77-feature',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      // First postComment call should be the superseded comment on the PR\n      expect(platformProvider.postComment).toHaveBeenNthCalledWith(\n        1,\n        'AgentSDE',\n        'agent-core',\n        77,\n        expect.any(String),\n      );\n      expect(platformProvider.closePR).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        77,\n      );\n      expect(platformProvider.deleteBranch).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        'feat/77-feature',\n      );\n      expect(task.prNumber).toBeNull();\n      expect(task.prBranch).toBeNull();\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n    });\n\n    it('restart: PR close failure logs warning and proceeds', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 88,\n        prBranch: 'feat/88-thing',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n      (platformProvider.closePR as jest.Mock).mockRejectedValueOnce(\n        new Error('GitHub API error: 500'),\n      );\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n    });\n\n    it('restart on active task: posts rejection comment, does not dispatch', async () => {\n      const activeTask = buildFakeTask({ status: 'active' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(activeTask);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      // rejection comment posted\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('restart does not overwrite task.directive', async () => {\n      const task = buildFakeTask({ directive: 'careful', status: 'blocked' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(task.directive).toBe('careful');\n    });\n  });\n\n  describe('replan directive', () => {\n    it('normal replan: uses resetForReplan, closes PR, deletes branch, clears artefacts, enqueues refine', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n        prNumber: 42,\n        prBranch: 'feat/42-my-feature',\n        replanAttempt: 0,\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n      (taskStateService.resetForReplan as jest.Mock).mockImplementation(\n        (t: TaskEntity): void => {\n          t.prNumber = null;\n          t.prBranch = null;\n          t.replanAttempt = (t.replanAttempt ?? 0) + 1;\n        },\n      );\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n      expect(taskStateService.resetForRestart).not.toHaveBeenCalled();\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(platformProvider.closePR).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        42,\n      );\n      expect(platformProvider.deleteBranch).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        'feat/42-my-feature',\n      );\n      expect(artefactsService.clearPlanArtefacts).toHaveBeenCalledWith(\n        '/tmp/tasks/AgentSDE/agent-core/gh-18',\n      );\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'replan_triggered',\n        'directive-service',\n        expect.objectContaining({ actor: 'zhjarvis', replan_attempt: 1 }),\n      );\n      expect(phaseHooksService.cleanupLabelsForReplan).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n      );\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'replan directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalled();\n    });\n\n    it('replan with no PR: skips PR close/branch delete, still clears artefacts', async () => {\n      const task = buildFakeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n        prNumber: null,\n        prBranch: null,\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(platformProvider.closePR).not.toHaveBeenCalled();\n      expect(platformProvider.deleteBranch).not.toHaveBeenCalled();\n      expect(artefactsService.clearPlanArtefacts).toHaveBeenCalled();\n      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'replan directive',\n      );\n    });\n\n    it('replan already in active refine: posts no-op comment, does not enqueue', async () => {\n      const task = buildFakeTask({\n        status: 'active',\n        currentPhase: 'refine',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('replan on succeeded task: posts rejection comment, does not enqueue', async () => {\n      const task = buildFakeTask({ status: 'succeeded' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('replan on failed task: posts rejection comment, does not enqueue', async () => {\n      const task = buildFakeTask({ status: 'failed' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n    });\n\n    it('replan: PR close failure logs warning and proceeds', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 99,\n        prBranch: 'feat/99-branch',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n      (platformProvider.closePR as jest.Mock).mockRejectedValueOnce(\n        new Error('GitHub API error: 500 — Internal Server Error'),\n      );\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      // Should still reset and enqueue even when PR close fails\n      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'replan directive',\n      );\n    });\n\n    it('replan: posts superseded comment on open PR before closing', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 55,\n        prBranch: 'feat/55-thing',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      // First postComment call should be the superseded comment on the PR\n      expect(platformProvider.postComment).toHaveBeenNthCalledWith(\n        1,\n        'AgentSDE',\n        'agent-core',\n        55,\n        expect.any(String),\n      );\n      expect(platformProvider.closePR).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        55,\n      );\n    });\n  });\n});\n","structuredPatch":[{"oldStart":353,"oldLines":8,"newStart":353,"newLines":8,"lines":["     it('retry on stuck task: resets phase, enqueues phase, posts confirmation', async () => {","       const stuckTask = buildFakeTask({","         status: 'stuck',","-        currentPhase: 'execute',","-        phaseExecute: 'active',","+        currentPhase: 'deliver',","+        phaseDeliver: 'active',","       });","       taskStateService.findByIssueAndRepo.mockResolvedValue(stuckTask);"," "]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01HaYFaxThGcE8auLiSWhSH8","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HdQTfw4usdPYr9DiXy9Pxz","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/directive/directive.service.spec.ts","old_string":"      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'execute',\n        'retry directive',\n      );","new_string":"      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'deliver',\n        'retry directive',\n      );"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":339,"cache_read_input_tokens":103319,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":339},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"8907dedc-4bbb-412e-8ca2-f3429a9d9de1"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HdQTfw4usdPYr9DiXy9Pxz","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/directive/directive.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"12cd242c-60cd-4315-9605-ad9a2bcbf5b9","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/directive/directive.service.spec.ts","oldString":"      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'execute',\n        'retry directive',\n      );","newString":"      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'deliver',\n        'retry directive',\n      );","originalFile":"import { Test, TestingModule } from '@nestjs/testing';\nimport { DirectiveService } from './directive.service';\nimport { TaskStateService } from '../task-state';\nimport { DispatchService } from '../dispatch';\nimport { InternalAdapterService } from '../internal-adapter';\nimport {\n  PLATFORM_PROVIDER,\n  PlatformProvider,\n} from '../platform/platform-provider.interface';\nimport { EventService } from '../event/event.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { TaskEntity } from '../database/entities';\n\nfunction buildFakeTask(overrides?: Partial<TaskEntity>): TaskEntity {\n  const task = new TaskEntity();\n  task.id = 1;\n  task.issue = 18;\n  task.repo = 'AgentSDE/agent-core';\n  task.directive = 'auto';\n  task.currentPhase = 'refine';\n  task.status = 'active';\n  task.gatePhase = null;\n  task.gateReason = null;\n  task.prNumber = null;\n  task.prBranch = null;\n  task.phaseRefine = 'pending';\n  task.phasePlan = 'pending';\n  task.phaseReview = 'pending';\n  task.phaseDeliver = 'pending';\n  task.phaseCompound = 'pending';\n  task.qualityGateOverride = false;\n  task.replanAttempt = 0;\n  task.createdAt = new Date();\n  task.updatedAt = new Date();\n  Object.assign(task, overrides);\n  return task;\n}\n\ndescribe('DirectiveService', () => {\n  let service: DirectiveService;\n  let taskStateService: jest.Mocked<\n    Pick<\n      TaskStateService,\n      | 'findByIssueAndRepo'\n      | 'save'\n      | 'clearGate'\n      | 'resetForRetry'\n      | 'resetForRestart'\n      | 'resetForReplan'\n      | 'setQualityGateOverride'\n    >\n  >;\n  let dispatchService: jest.Mocked<Pick<DispatchService, 'dispatch'>>;\n  let internalAdapterService: jest.Mocked<\n    Pick<InternalAdapterService, 'enqueuePhase'>\n  >;\n  let platformProvider: jest.Mocked<\n    Pick<PlatformProvider, 'closePR' | 'deleteBranch' | 'postComment'>\n  >;\n  let eventService: jest.Mocked<Pick<EventService, 'record'>>;\n  let phaseHooksService: jest.Mocked<\n    Pick<PhaseHooksService, 'cleanupLabelsForReplan'>\n  >;\n  let artefactsService: jest.Mocked<\n    Pick<ArtefactsService, 'clearPlanArtefacts' | 'buildCentralTaskDir'>\n  >;\n\n  beforeEach(async () => {\n    taskStateService = {\n      findByIssueAndRepo: jest.fn().mockResolvedValue(buildFakeTask()),\n      save: jest.fn().mockImplementation((t: TaskEntity) => Promise.resolve(t)),\n      clearGate: jest.fn(),\n      resetForRetry: jest.fn(),\n      resetForRestart: jest.fn(),\n      resetForReplan: jest.fn(),\n      setQualityGateOverride: jest.fn(),\n    };\n\n    artefactsService = {\n      clearPlanArtefacts: jest.fn().mockResolvedValue(undefined),\n      buildCentralTaskDir: jest\n        .fn()\n        .mockReturnValue('/tmp/tasks/AgentSDE/agent-core/gh-18'),\n    };\n\n    dispatchService = {\n      dispatch: jest.fn().mockResolvedValue(null),\n    };\n\n    internalAdapterService = {\n      enqueuePhase: jest.fn().mockResolvedValue(undefined),\n    };\n\n    platformProvider = {\n      closePR: jest.fn().mockResolvedValue(undefined),\n      deleteBranch: jest.fn().mockResolvedValue(undefined),\n      postComment: jest.fn().mockResolvedValue(undefined),\n    };\n\n    eventService = {\n      record: jest.fn().mockResolvedValue(undefined),\n    };\n\n    phaseHooksService = {\n      cleanupLabelsForReplan: jest.fn().mockResolvedValue(undefined),\n    };\n\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        DirectiveService,\n        { provide: TaskStateService, useValue: taskStateService },\n        { provide: DispatchService, useValue: dispatchService },\n        { provide: InternalAdapterService, useValue: internalAdapterService },\n        { provide: PLATFORM_PROVIDER, useValue: platformProvider },\n        { provide: EventService, useValue: eventService },\n        { provide: PhaseHooksService, useValue: phaseHooksService },\n        { provide: ArtefactsService, useValue: artefactsService },\n      ],\n    }).compile();\n\n    service = module.get<DirectiveService>(DirectiveService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('auto: sets directive and dispatches', async () => {\n    await service.applyDirective(\n      18,\n      'auto',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.findByIssueAndRepo).toHaveBeenCalledWith(\n      18,\n      'AgentSDE/agent-core',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n    expect(taskStateService.clearGate).not.toHaveBeenCalled();\n  });\n\n  it('quick: sets directive and dispatches', async () => {\n    await service.applyDirective(\n      18,\n      'quick',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n  });\n\n  it('careful: sets directive and dispatches', async () => {\n    await service.applyDirective(\n      18,\n      'careful',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n  });\n\n  describe('refine directive', () => {\n    it('refine on gated task: clears gate, enqueues refine phase, posts confirmation', async () => {\n      const gatedTask = buildFakeTask({\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'awaiting approval',\n        directive: 'careful',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).toHaveBeenCalledWith(gatedTask);\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'refine directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('refine on active task: posts rejection comment, no dispatch/enqueue', async () => {\n      const activeTask = buildFakeTask({ status: 'active' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(activeTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('refine on succeeded task: posts terminal rejection comment, no dispatch/enqueue', async () => {\n      const succeededTask = buildFakeTask({ status: 'succeeded' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(succeededTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('refine on failed task: posts terminal rejection comment, no dispatch/enqueue', async () => {\n      const failedTask = buildFakeTask({ status: 'failed' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n    });\n\n    it('refine does not overwrite task.directive', async () => {\n      const gatedTask = buildFakeTask({\n        status: 'gated',\n        gatePhase: 'plan',\n        directive: 'careful',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(gatedTask.directive).toBe('careful');\n    });\n  });\n\n  it('approve: dispatches directive without pre-clearing gate (gate cleared by phase-router)', async () => {\n    const gatedTask = buildFakeTask({\n      gatePhase: 'review',\n      gateReason: 'needs approval',\n      status: 'gated',\n    });\n    taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n\n    await service.applyDirective(\n      18,\n      'approve',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    // Gate must NOT be cleared here — phase-router reads gatePhase in resolvePhase\n    // to determine which phase to resume. Pre-clearing leaves gatePhase=null and\n    // the router returns null, leaving the task stuck at status=gated.\n    expect(taskStateService.clearGate).not.toHaveBeenCalled();\n    expect(gatedTask.gatePhase).toBe('review'); // preserved for phase-router\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n  });\n\n  it('stop: sets status=paused and does NOT dispatch', async () => {\n    await service.applyDirective(\n      18,\n      'stop',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).not.toHaveBeenCalled();\n  });\n\n  it('posts a GitHub confirmation comment for all directives', async () => {\n    await service.applyDirective(\n      18,\n      'auto',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(platformProvider.postComment).toHaveBeenCalledWith(\n      'AgentSDE',\n      'agent-core',\n      18,\n      expect.any(String),\n    );\n  });\n\n  it('returns early if no task found', async () => {\n    taskStateService.findByIssueAndRepo.mockResolvedValue(null);\n    await service.applyDirective(\n      18,\n      'auto',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(dispatchService.dispatch).not.toHaveBeenCalled();\n  });\n\n  describe('retry directive', () => {\n    it('retry on stuck task: resets phase, enqueues phase, posts confirmation', async () => {\n      const stuckTask = buildFakeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n        phaseDeliver: 'active',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(stuckTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).toHaveBeenCalledWith(stuckTask);\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'execute',\n        'retry directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalled();\n    });\n\n    it('retry on blocked task: resets phase, enqueues phase', async () => {\n      const blockedTask = buildFakeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(blockedTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).toHaveBeenCalledWith(blockedTask);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'plan',\n        'retry directive',\n      );\n    });\n\n    it('retry on succeeded task: posts rejection comment, does not dispatch', async () => {\n      const succeededTask = buildFakeTask({ status: 'succeeded' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(succeededTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      // rejection comment posted\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('retry on failed task: posts rejection comment, does not dispatch', async () => {\n      const failedTask = buildFakeTask({ status: 'failed' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n    });\n\n    it('retry does not overwrite task.directive', async () => {\n      const task = buildFakeTask({ directive: 'careful', status: 'stuck' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(task.directive).toBe('careful');\n    });\n  });\n\n  describe('skip-quality-gate directive', () => {\n    it('sets qualityGateOverride=true on the task and posts confirmation', async () => {\n      const task = buildFakeTask({ status: 'gated', currentPhase: 'deliver' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'skip-quality-gate',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        true,\n      );\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('does not dispatch a directive event', async () => {\n      await service.applyDirective(\n        18,\n        'skip-quality-gate',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('restart directive', () => {\n    it('restart on non-active task: resets all phases, clears PR state, enqueues refine, posts confirmation', async () => {\n      const failedTask = buildFakeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n        prNumber: null,\n        prBranch: null,\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(failedTask);\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on stuck task: resets all phases, enqueues refine', async () => {\n      const stuckTask = buildFakeTask({ status: 'stuck', prNumber: null });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(stuckTask);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(stuckTask);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n    });\n\n    it('restart with open PR: posts superseded comment, closes PR, clears prNumber/prBranch', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 77,\n        prBranch: 'feat/77-feature',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      // First postComment call should be the superseded comment on the PR\n      expect(platformProvider.postComment).toHaveBeenNthCalledWith(\n        1,\n        'AgentSDE',\n        'agent-core',\n        77,\n        expect.any(String),\n      );\n      expect(platformProvider.closePR).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        77,\n      );\n      expect(platformProvider.deleteBranch).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        'feat/77-feature',\n      );\n      expect(task.prNumber).toBeNull();\n      expect(task.prBranch).toBeNull();\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n    });\n\n    it('restart: PR close failure logs warning and proceeds', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 88,\n        prBranch: 'feat/88-thing',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n      (platformProvider.closePR as jest.Mock).mockRejectedValueOnce(\n        new Error('GitHub API error: 500'),\n      );\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n    });\n\n    it('restart on active task: posts rejection comment, does not dispatch', async () => {\n      const activeTask = buildFakeTask({ status: 'active' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(activeTask);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      // rejection comment posted\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('restart does not overwrite task.directive', async () => {\n      const task = buildFakeTask({ directive: 'careful', status: 'blocked' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(task.directive).toBe('careful');\n    });\n  });\n\n  describe('replan directive', () => {\n    it('normal replan: uses resetForReplan, closes PR, deletes branch, clears artefacts, enqueues refine', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n        prNumber: 42,\n        prBranch: 'feat/42-my-feature',\n        replanAttempt: 0,\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n      (taskStateService.resetForReplan as jest.Mock).mockImplementation(\n        (t: TaskEntity): void => {\n          t.prNumber = null;\n          t.prBranch = null;\n          t.replanAttempt = (t.replanAttempt ?? 0) + 1;\n        },\n      );\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n      expect(taskStateService.resetForRestart).not.toHaveBeenCalled();\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(platformProvider.closePR).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        42,\n      );\n      expect(platformProvider.deleteBranch).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        'feat/42-my-feature',\n      );\n      expect(artefactsService.clearPlanArtefacts).toHaveBeenCalledWith(\n        '/tmp/tasks/AgentSDE/agent-core/gh-18',\n      );\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'replan_triggered',\n        'directive-service',\n        expect.objectContaining({ actor: 'zhjarvis', replan_attempt: 1 }),\n      );\n      expect(phaseHooksService.cleanupLabelsForReplan).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n      );\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'replan directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalled();\n    });\n\n    it('replan with no PR: skips PR close/branch delete, still clears artefacts', async () => {\n      const task = buildFakeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n        prNumber: null,\n        prBranch: null,\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(platformProvider.closePR).not.toHaveBeenCalled();\n      expect(platformProvider.deleteBranch).not.toHaveBeenCalled();\n      expect(artefactsService.clearPlanArtefacts).toHaveBeenCalled();\n      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'replan directive',\n      );\n    });\n\n    it('replan already in active refine: posts no-op comment, does not enqueue', async () => {\n      const task = buildFakeTask({\n        status: 'active',\n        currentPhase: 'refine',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('replan on succeeded task: posts rejection comment, does not enqueue', async () => {\n      const task = buildFakeTask({ status: 'succeeded' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('replan on failed task: posts rejection comment, does not enqueue', async () => {\n      const task = buildFakeTask({ status: 'failed' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n    });\n\n    it('replan: PR close failure logs warning and proceeds', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 99,\n        prBranch: 'feat/99-branch',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n      (platformProvider.closePR as jest.Mock).mockRejectedValueOnce(\n        new Error('GitHub API error: 500 — Internal Server Error'),\n      );\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      // Should still reset and enqueue even when PR close fails\n      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'replan directive',\n      );\n    });\n\n    it('replan: posts superseded comment on open PR before closing', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 55,\n        prBranch: 'feat/55-thing',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      // First postComment call should be the superseded comment on the PR\n      expect(platformProvider.postComment).toHaveBeenNthCalledWith(\n        1,\n        'AgentSDE',\n        'agent-core',\n        55,\n        expect.any(String),\n      );\n      expect(platformProvider.closePR).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        55,\n      );\n    });\n  });\n});\n","structuredPatch":[{"oldStart":371,"oldLines":7,"newStart":371,"newLines":7,"lines":["       expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(","         18,","         'AgentSDE/agent-core',","-        'execute',","+        'deliver',","         'retry directive',","       );","       expect(dispatchService.dispatch).not.toHaveBeenCalled();"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_012pxGzHrtiTxuA8ZBGYt56m","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DJCr21ZVht4zTdX7LQiQWJ","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/directive/directive.service.spec.ts","old_string":"    it('restart on non-active task: resets all phases, clears PR state, enqueues refine, posts confirmation', async () => {\n      const failedTask = buildFakeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n        prNumber: null,\n        prBranch: null,\n      });","new_string":"    it('restart on non-active task: resets all phases, clears PR state, enqueues refine, posts confirmation', async () => {\n      const failedTask = buildFakeTask({\n        status: 'failed',\n        currentPhase: 'deliver',\n        prNumber: null,\n        prBranch: null,\n      });"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":311,"cache_read_input_tokens":103658,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":311},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"59fffa07-fc8e-467b-aab8-3a7a93ea20d0"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DJCr21ZVht4zTdX7LQiQWJ","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/directive/directive.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"c7435880-8d5a-4222-8c61-62cdfcc9da7a","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/directive/directive.service.spec.ts","oldString":"    it('restart on non-active task: resets all phases, clears PR state, enqueues refine, posts confirmation', async () => {\n      const failedTask = buildFakeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n        prNumber: null,\n        prBranch: null,\n      });","newString":"    it('restart on non-active task: resets all phases, clears PR state, enqueues refine, posts confirmation', async () => {\n      const failedTask = buildFakeTask({\n        status: 'failed',\n        currentPhase: 'deliver',\n        prNumber: null,\n        prBranch: null,\n      });","originalFile":"import { Test, TestingModule } from '@nestjs/testing';\nimport { DirectiveService } from './directive.service';\nimport { TaskStateService } from '../task-state';\nimport { DispatchService } from '../dispatch';\nimport { InternalAdapterService } from '../internal-adapter';\nimport {\n  PLATFORM_PROVIDER,\n  PlatformProvider,\n} from '../platform/platform-provider.interface';\nimport { EventService } from '../event/event.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { TaskEntity } from '../database/entities';\n\nfunction buildFakeTask(overrides?: Partial<TaskEntity>): TaskEntity {\n  const task = new TaskEntity();\n  task.id = 1;\n  task.issue = 18;\n  task.repo = 'AgentSDE/agent-core';\n  task.directive = 'auto';\n  task.currentPhase = 'refine';\n  task.status = 'active';\n  task.gatePhase = null;\n  task.gateReason = null;\n  task.prNumber = null;\n  task.prBranch = null;\n  task.phaseRefine = 'pending';\n  task.phasePlan = 'pending';\n  task.phaseReview = 'pending';\n  task.phaseDeliver = 'pending';\n  task.phaseCompound = 'pending';\n  task.qualityGateOverride = false;\n  task.replanAttempt = 0;\n  task.createdAt = new Date();\n  task.updatedAt = new Date();\n  Object.assign(task, overrides);\n  return task;\n}\n\ndescribe('DirectiveService', () => {\n  let service: DirectiveService;\n  let taskStateService: jest.Mocked<\n    Pick<\n      TaskStateService,\n      | 'findByIssueAndRepo'\n      | 'save'\n      | 'clearGate'\n      | 'resetForRetry'\n      | 'resetForRestart'\n      | 'resetForReplan'\n      | 'setQualityGateOverride'\n    >\n  >;\n  let dispatchService: jest.Mocked<Pick<DispatchService, 'dispatch'>>;\n  let internalAdapterService: jest.Mocked<\n    Pick<InternalAdapterService, 'enqueuePhase'>\n  >;\n  let platformProvider: jest.Mocked<\n    Pick<PlatformProvider, 'closePR' | 'deleteBranch' | 'postComment'>\n  >;\n  let eventService: jest.Mocked<Pick<EventService, 'record'>>;\n  let phaseHooksService: jest.Mocked<\n    Pick<PhaseHooksService, 'cleanupLabelsForReplan'>\n  >;\n  let artefactsService: jest.Mocked<\n    Pick<ArtefactsService, 'clearPlanArtefacts' | 'buildCentralTaskDir'>\n  >;\n\n  beforeEach(async () => {\n    taskStateService = {\n      findByIssueAndRepo: jest.fn().mockResolvedValue(buildFakeTask()),\n      save: jest.fn().mockImplementation((t: TaskEntity) => Promise.resolve(t)),\n      clearGate: jest.fn(),\n      resetForRetry: jest.fn(),\n      resetForRestart: jest.fn(),\n      resetForReplan: jest.fn(),\n      setQualityGateOverride: jest.fn(),\n    };\n\n    artefactsService = {\n      clearPlanArtefacts: jest.fn().mockResolvedValue(undefined),\n      buildCentralTaskDir: jest\n        .fn()\n        .mockReturnValue('/tmp/tasks/AgentSDE/agent-core/gh-18'),\n    };\n\n    dispatchService = {\n      dispatch: jest.fn().mockResolvedValue(null),\n    };\n\n    internalAdapterService = {\n      enqueuePhase: jest.fn().mockResolvedValue(undefined),\n    };\n\n    platformProvider = {\n      closePR: jest.fn().mockResolvedValue(undefined),\n      deleteBranch: jest.fn().mockResolvedValue(undefined),\n      postComment: jest.fn().mockResolvedValue(undefined),\n    };\n\n    eventService = {\n      record: jest.fn().mockResolvedValue(undefined),\n    };\n\n    phaseHooksService = {\n      cleanupLabelsForReplan: jest.fn().mockResolvedValue(undefined),\n    };\n\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        DirectiveService,\n        { provide: TaskStateService, useValue: taskStateService },\n        { provide: DispatchService, useValue: dispatchService },\n        { provide: InternalAdapterService, useValue: internalAdapterService },\n        { provide: PLATFORM_PROVIDER, useValue: platformProvider },\n        { provide: EventService, useValue: eventService },\n        { provide: PhaseHooksService, useValue: phaseHooksService },\n        { provide: ArtefactsService, useValue: artefactsService },\n      ],\n    }).compile();\n\n    service = module.get<DirectiveService>(DirectiveService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('auto: sets directive and dispatches', async () => {\n    await service.applyDirective(\n      18,\n      'auto',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.findByIssueAndRepo).toHaveBeenCalledWith(\n      18,\n      'AgentSDE/agent-core',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n    expect(taskStateService.clearGate).not.toHaveBeenCalled();\n  });\n\n  it('quick: sets directive and dispatches', async () => {\n    await service.applyDirective(\n      18,\n      'quick',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n  });\n\n  it('careful: sets directive and dispatches', async () => {\n    await service.applyDirective(\n      18,\n      'careful',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n  });\n\n  describe('refine directive', () => {\n    it('refine on gated task: clears gate, enqueues refine phase, posts confirmation', async () => {\n      const gatedTask = buildFakeTask({\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'awaiting approval',\n        directive: 'careful',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).toHaveBeenCalledWith(gatedTask);\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'refine directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('refine on active task: posts rejection comment, no dispatch/enqueue', async () => {\n      const activeTask = buildFakeTask({ status: 'active' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(activeTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('refine on succeeded task: posts terminal rejection comment, no dispatch/enqueue', async () => {\n      const succeededTask = buildFakeTask({ status: 'succeeded' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(succeededTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('refine on failed task: posts terminal rejection comment, no dispatch/enqueue', async () => {\n      const failedTask = buildFakeTask({ status: 'failed' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n    });\n\n    it('refine does not overwrite task.directive', async () => {\n      const gatedTask = buildFakeTask({\n        status: 'gated',\n        gatePhase: 'plan',\n        directive: 'careful',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(gatedTask.directive).toBe('careful');\n    });\n  });\n\n  it('approve: dispatches directive without pre-clearing gate (gate cleared by phase-router)', async () => {\n    const gatedTask = buildFakeTask({\n      gatePhase: 'review',\n      gateReason: 'needs approval',\n      status: 'gated',\n    });\n    taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n\n    await service.applyDirective(\n      18,\n      'approve',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    // Gate must NOT be cleared here — phase-router reads gatePhase in resolvePhase\n    // to determine which phase to resume. Pre-clearing leaves gatePhase=null and\n    // the router returns null, leaving the task stuck at status=gated.\n    expect(taskStateService.clearGate).not.toHaveBeenCalled();\n    expect(gatedTask.gatePhase).toBe('review'); // preserved for phase-router\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n  });\n\n  it('stop: sets status=paused and does NOT dispatch', async () => {\n    await service.applyDirective(\n      18,\n      'stop',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).not.toHaveBeenCalled();\n  });\n\n  it('posts a GitHub confirmation comment for all directives', async () => {\n    await service.applyDirective(\n      18,\n      'auto',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(platformProvider.postComment).toHaveBeenCalledWith(\n      'AgentSDE',\n      'agent-core',\n      18,\n      expect.any(String),\n    );\n  });\n\n  it('returns early if no task found', async () => {\n    taskStateService.findByIssueAndRepo.mockResolvedValue(null);\n    await service.applyDirective(\n      18,\n      'auto',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(dispatchService.dispatch).not.toHaveBeenCalled();\n  });\n\n  describe('retry directive', () => {\n    it('retry on stuck task: resets phase, enqueues phase, posts confirmation', async () => {\n      const stuckTask = buildFakeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n        phaseDeliver: 'active',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(stuckTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).toHaveBeenCalledWith(stuckTask);\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'deliver',\n        'retry directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalled();\n    });\n\n    it('retry on blocked task: resets phase, enqueues phase', async () => {\n      const blockedTask = buildFakeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(blockedTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).toHaveBeenCalledWith(blockedTask);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'plan',\n        'retry directive',\n      );\n    });\n\n    it('retry on succeeded task: posts rejection comment, does not dispatch', async () => {\n      const succeededTask = buildFakeTask({ status: 'succeeded' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(succeededTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      // rejection comment posted\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('retry on failed task: posts rejection comment, does not dispatch', async () => {\n      const failedTask = buildFakeTask({ status: 'failed' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n    });\n\n    it('retry does not overwrite task.directive', async () => {\n      const task = buildFakeTask({ directive: 'careful', status: 'stuck' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(task.directive).toBe('careful');\n    });\n  });\n\n  describe('skip-quality-gate directive', () => {\n    it('sets qualityGateOverride=true on the task and posts confirmation', async () => {\n      const task = buildFakeTask({ status: 'gated', currentPhase: 'deliver' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'skip-quality-gate',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        true,\n      );\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('does not dispatch a directive event', async () => {\n      await service.applyDirective(\n        18,\n        'skip-quality-gate',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('restart directive', () => {\n    it('restart on non-active task: resets all phases, clears PR state, enqueues refine, posts confirmation', async () => {\n      const failedTask = buildFakeTask({\n        status: 'failed',\n        currentPhase: 'execute',\n        prNumber: null,\n        prBranch: null,\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(failedTask);\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on stuck task: resets all phases, enqueues refine', async () => {\n      const stuckTask = buildFakeTask({ status: 'stuck', prNumber: null });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(stuckTask);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(stuckTask);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n    });\n\n    it('restart with open PR: posts superseded comment, closes PR, clears prNumber/prBranch', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 77,\n        prBranch: 'feat/77-feature',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      // First postComment call should be the superseded comment on the PR\n      expect(platformProvider.postComment).toHaveBeenNthCalledWith(\n        1,\n        'AgentSDE',\n        'agent-core',\n        77,\n        expect.any(String),\n      );\n      expect(platformProvider.closePR).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        77,\n      );\n      expect(platformProvider.deleteBranch).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        'feat/77-feature',\n      );\n      expect(task.prNumber).toBeNull();\n      expect(task.prBranch).toBeNull();\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n    });\n\n    it('restart: PR close failure logs warning and proceeds', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 88,\n        prBranch: 'feat/88-thing',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n      (platformProvider.closePR as jest.Mock).mockRejectedValueOnce(\n        new Error('GitHub API error: 500'),\n      );\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n    });\n\n    it('restart on active task: posts rejection comment, does not dispatch', async () => {\n      const activeTask = buildFakeTask({ status: 'active' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(activeTask);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      // rejection comment posted\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('restart does not overwrite task.directive', async () => {\n      const task = buildFakeTask({ directive: 'careful', status: 'blocked' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(task.directive).toBe('careful');\n    });\n  });\n\n  describe('replan directive', () => {\n    it('normal replan: uses resetForReplan, closes PR, deletes branch, clears artefacts, enqueues refine', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n        prNumber: 42,\n        prBranch: 'feat/42-my-feature',\n        replanAttempt: 0,\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n      (taskStateService.resetForReplan as jest.Mock).mockImplementation(\n        (t: TaskEntity): void => {\n          t.prNumber = null;\n          t.prBranch = null;\n          t.replanAttempt = (t.replanAttempt ?? 0) + 1;\n        },\n      );\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n      expect(taskStateService.resetForRestart).not.toHaveBeenCalled();\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(platformProvider.closePR).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        42,\n      );\n      expect(platformProvider.deleteBranch).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        'feat/42-my-feature',\n      );\n      expect(artefactsService.clearPlanArtefacts).toHaveBeenCalledWith(\n        '/tmp/tasks/AgentSDE/agent-core/gh-18',\n      );\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'replan_triggered',\n        'directive-service',\n        expect.objectContaining({ actor: 'zhjarvis', replan_attempt: 1 }),\n      );\n      expect(phaseHooksService.cleanupLabelsForReplan).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n      );\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'replan directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalled();\n    });\n\n    it('replan with no PR: skips PR close/branch delete, still clears artefacts', async () => {\n      const task = buildFakeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n        prNumber: null,\n        prBranch: null,\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(platformProvider.closePR).not.toHaveBeenCalled();\n      expect(platformProvider.deleteBranch).not.toHaveBeenCalled();\n      expect(artefactsService.clearPlanArtefacts).toHaveBeenCalled();\n      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'replan directive',\n      );\n    });\n\n    it('replan already in active refine: posts no-op comment, does not enqueue', async () => {\n      const task = buildFakeTask({\n        status: 'active',\n        currentPhase: 'refine',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('replan on succeeded task: posts rejection comment, does not enqueue', async () => {\n      const task = buildFakeTask({ status: 'succeeded' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('replan on failed task: posts rejection comment, does not enqueue', async () => {\n      const task = buildFakeTask({ status: 'failed' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n    });\n\n    it('replan: PR close failure logs warning and proceeds', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 99,\n        prBranch: 'feat/99-branch',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n      (platformProvider.closePR as jest.Mock).mockRejectedValueOnce(\n        new Error('GitHub API error: 500 — Internal Server Error'),\n      );\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      // Should still reset and enqueue even when PR close fails\n      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'replan directive',\n      );\n    });\n\n    it('replan: posts superseded comment on open PR before closing', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 55,\n        prBranch: 'feat/55-thing',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      // First postComment call should be the superseded comment on the PR\n      expect(platformProvider.postComment).toHaveBeenNthCalledWith(\n        1,\n        'AgentSDE',\n        'agent-core',\n        55,\n        expect.any(String),\n      );\n      expect(platformProvider.closePR).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        55,\n      );\n    });\n  });\n});\n","structuredPatch":[{"oldStart":501,"oldLines":7,"newStart":501,"newLines":7,"lines":["     it('restart on non-active task: resets all phases, clears PR state, enqueues refine, posts confirmation', async () => {","       const failedTask = buildFakeTask({","         status: 'failed',","-        currentPhase: 'execute',","+        currentPhase: 'deliver',","         prNumber: null,","         prBranch: null,","       });"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01F1oqcd5Cqa4Tt4VL1pHh1K","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DJGMZuknV6iaaQyWXqj8HU","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/directive/directive.service.spec.ts","old_string":"    it('normal replan: uses resetForReplan, closes PR, deletes branch, clears artefacts, enqueues refine', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n        prNumber: 42,","new_string":"    it('normal replan: uses resetForReplan, closes PR, deletes branch, clears artefacts, enqueues refine', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        currentPhase: 'deliver',\n        prNumber: 42,"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":367,"cache_read_input_tokens":103969,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":367},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"8beedd2d-6ab0-4cea-9e60-f3a505aec8b5"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DJGMZuknV6iaaQyWXqj8HU","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/directive/directive.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"3ac262da-8d16-40f2-abdc-78dcd980f3a5","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/directive/directive.service.spec.ts","oldString":"    it('normal replan: uses resetForReplan, closes PR, deletes branch, clears artefacts, enqueues refine', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n        prNumber: 42,","newString":"    it('normal replan: uses resetForReplan, closes PR, deletes branch, clears artefacts, enqueues refine', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        currentPhase: 'deliver',\n        prNumber: 42,","originalFile":"import { Test, TestingModule } from '@nestjs/testing';\nimport { DirectiveService } from './directive.service';\nimport { TaskStateService } from '../task-state';\nimport { DispatchService } from '../dispatch';\nimport { InternalAdapterService } from '../internal-adapter';\nimport {\n  PLATFORM_PROVIDER,\n  PlatformProvider,\n} from '../platform/platform-provider.interface';\nimport { EventService } from '../event/event.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { ArtefactsService } from '../artefacts/artefacts.service';\nimport { TaskEntity } from '../database/entities';\n\nfunction buildFakeTask(overrides?: Partial<TaskEntity>): TaskEntity {\n  const task = new TaskEntity();\n  task.id = 1;\n  task.issue = 18;\n  task.repo = 'AgentSDE/agent-core';\n  task.directive = 'auto';\n  task.currentPhase = 'refine';\n  task.status = 'active';\n  task.gatePhase = null;\n  task.gateReason = null;\n  task.prNumber = null;\n  task.prBranch = null;\n  task.phaseRefine = 'pending';\n  task.phasePlan = 'pending';\n  task.phaseReview = 'pending';\n  task.phaseDeliver = 'pending';\n  task.phaseCompound = 'pending';\n  task.qualityGateOverride = false;\n  task.replanAttempt = 0;\n  task.createdAt = new Date();\n  task.updatedAt = new Date();\n  Object.assign(task, overrides);\n  return task;\n}\n\ndescribe('DirectiveService', () => {\n  let service: DirectiveService;\n  let taskStateService: jest.Mocked<\n    Pick<\n      TaskStateService,\n      | 'findByIssueAndRepo'\n      | 'save'\n      | 'clearGate'\n      | 'resetForRetry'\n      | 'resetForRestart'\n      | 'resetForReplan'\n      | 'setQualityGateOverride'\n    >\n  >;\n  let dispatchService: jest.Mocked<Pick<DispatchService, 'dispatch'>>;\n  let internalAdapterService: jest.Mocked<\n    Pick<InternalAdapterService, 'enqueuePhase'>\n  >;\n  let platformProvider: jest.Mocked<\n    Pick<PlatformProvider, 'closePR' | 'deleteBranch' | 'postComment'>\n  >;\n  let eventService: jest.Mocked<Pick<EventService, 'record'>>;\n  let phaseHooksService: jest.Mocked<\n    Pick<PhaseHooksService, 'cleanupLabelsForReplan'>\n  >;\n  let artefactsService: jest.Mocked<\n    Pick<ArtefactsService, 'clearPlanArtefacts' | 'buildCentralTaskDir'>\n  >;\n\n  beforeEach(async () => {\n    taskStateService = {\n      findByIssueAndRepo: jest.fn().mockResolvedValue(buildFakeTask()),\n      save: jest.fn().mockImplementation((t: TaskEntity) => Promise.resolve(t)),\n      clearGate: jest.fn(),\n      resetForRetry: jest.fn(),\n      resetForRestart: jest.fn(),\n      resetForReplan: jest.fn(),\n      setQualityGateOverride: jest.fn(),\n    };\n\n    artefactsService = {\n      clearPlanArtefacts: jest.fn().mockResolvedValue(undefined),\n      buildCentralTaskDir: jest\n        .fn()\n        .mockReturnValue('/tmp/tasks/AgentSDE/agent-core/gh-18'),\n    };\n\n    dispatchService = {\n      dispatch: jest.fn().mockResolvedValue(null),\n    };\n\n    internalAdapterService = {\n      enqueuePhase: jest.fn().mockResolvedValue(undefined),\n    };\n\n    platformProvider = {\n      closePR: jest.fn().mockResolvedValue(undefined),\n      deleteBranch: jest.fn().mockResolvedValue(undefined),\n      postComment: jest.fn().mockResolvedValue(undefined),\n    };\n\n    eventService = {\n      record: jest.fn().mockResolvedValue(undefined),\n    };\n\n    phaseHooksService = {\n      cleanupLabelsForReplan: jest.fn().mockResolvedValue(undefined),\n    };\n\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        DirectiveService,\n        { provide: TaskStateService, useValue: taskStateService },\n        { provide: DispatchService, useValue: dispatchService },\n        { provide: InternalAdapterService, useValue: internalAdapterService },\n        { provide: PLATFORM_PROVIDER, useValue: platformProvider },\n        { provide: EventService, useValue: eventService },\n        { provide: PhaseHooksService, useValue: phaseHooksService },\n        { provide: ArtefactsService, useValue: artefactsService },\n      ],\n    }).compile();\n\n    service = module.get<DirectiveService>(DirectiveService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('auto: sets directive and dispatches', async () => {\n    await service.applyDirective(\n      18,\n      'auto',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.findByIssueAndRepo).toHaveBeenCalledWith(\n      18,\n      'AgentSDE/agent-core',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n    expect(taskStateService.clearGate).not.toHaveBeenCalled();\n  });\n\n  it('quick: sets directive and dispatches', async () => {\n    await service.applyDirective(\n      18,\n      'quick',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n  });\n\n  it('careful: sets directive and dispatches', async () => {\n    await service.applyDirective(\n      18,\n      'careful',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n  });\n\n  describe('refine directive', () => {\n    it('refine on gated task: clears gate, enqueues refine phase, posts confirmation', async () => {\n      const gatedTask = buildFakeTask({\n        status: 'gated',\n        gatePhase: 'plan',\n        gateReason: 'awaiting approval',\n        directive: 'careful',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).toHaveBeenCalledWith(gatedTask);\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'refine directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('refine on active task: posts rejection comment, no dispatch/enqueue', async () => {\n      const activeTask = buildFakeTask({ status: 'active' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(activeTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('refine on succeeded task: posts terminal rejection comment, no dispatch/enqueue', async () => {\n      const succeededTask = buildFakeTask({ status: 'succeeded' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(succeededTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('refine on failed task: posts terminal rejection comment, no dispatch/enqueue', async () => {\n      const failedTask = buildFakeTask({ status: 'failed' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.clearGate).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n    });\n\n    it('refine does not overwrite task.directive', async () => {\n      const gatedTask = buildFakeTask({\n        status: 'gated',\n        gatePhase: 'plan',\n        directive: 'careful',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n\n      await service.applyDirective(\n        18,\n        'refine',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(gatedTask.directive).toBe('careful');\n    });\n  });\n\n  it('approve: dispatches directive without pre-clearing gate (gate cleared by phase-router)', async () => {\n    const gatedTask = buildFakeTask({\n      gatePhase: 'review',\n      gateReason: 'needs approval',\n      status: 'gated',\n    });\n    taskStateService.findByIssueAndRepo.mockResolvedValue(gatedTask);\n\n    await service.applyDirective(\n      18,\n      'approve',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    // Gate must NOT be cleared here — phase-router reads gatePhase in resolvePhase\n    // to determine which phase to resume. Pre-clearing leaves gatePhase=null and\n    // the router returns null, leaving the task stuck at status=gated.\n    expect(taskStateService.clearGate).not.toHaveBeenCalled();\n    expect(gatedTask.gatePhase).toBe('review'); // preserved for phase-router\n    expect(dispatchService.dispatch).toHaveBeenCalledTimes(1);\n  });\n\n  it('stop: sets status=paused and does NOT dispatch', async () => {\n    await service.applyDirective(\n      18,\n      'stop',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(dispatchService.dispatch).not.toHaveBeenCalled();\n  });\n\n  it('posts a GitHub confirmation comment for all directives', async () => {\n    await service.applyDirective(\n      18,\n      'auto',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(platformProvider.postComment).toHaveBeenCalledWith(\n      'AgentSDE',\n      'agent-core',\n      18,\n      expect.any(String),\n    );\n  });\n\n  it('returns early if no task found', async () => {\n    taskStateService.findByIssueAndRepo.mockResolvedValue(null);\n    await service.applyDirective(\n      18,\n      'auto',\n      'AgentSDE/agent-core',\n      123,\n      'zhjarvis',\n    );\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(dispatchService.dispatch).not.toHaveBeenCalled();\n  });\n\n  describe('retry directive', () => {\n    it('retry on stuck task: resets phase, enqueues phase, posts confirmation', async () => {\n      const stuckTask = buildFakeTask({\n        status: 'stuck',\n        currentPhase: 'deliver',\n        phaseDeliver: 'active',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(stuckTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).toHaveBeenCalledWith(stuckTask);\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'deliver',\n        'retry directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalled();\n    });\n\n    it('retry on blocked task: resets phase, enqueues phase', async () => {\n      const blockedTask = buildFakeTask({\n        status: 'blocked',\n        currentPhase: 'plan',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(blockedTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).toHaveBeenCalledWith(blockedTask);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'plan',\n        'retry directive',\n      );\n    });\n\n    it('retry on succeeded task: posts rejection comment, does not dispatch', async () => {\n      const succeededTask = buildFakeTask({ status: 'succeeded' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(succeededTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      // rejection comment posted\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('retry on failed task: posts rejection comment, does not dispatch', async () => {\n      const failedTask = buildFakeTask({ status: 'failed' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRetry).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n    });\n\n    it('retry does not overwrite task.directive', async () => {\n      const task = buildFakeTask({ directive: 'careful', status: 'stuck' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'retry',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(task.directive).toBe('careful');\n    });\n  });\n\n  describe('skip-quality-gate directive', () => {\n    it('sets qualityGateOverride=true on the task and posts confirmation', async () => {\n      const task = buildFakeTask({ status: 'gated', currentPhase: 'deliver' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'skip-quality-gate',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.setQualityGateOverride).toHaveBeenCalledWith(\n        task,\n        true,\n      );\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('does not dispatch a directive event', async () => {\n      await service.applyDirective(\n        18,\n        'skip-quality-gate',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('restart directive', () => {\n    it('restart on non-active task: resets all phases, clears PR state, enqueues refine, posts confirmation', async () => {\n      const failedTask = buildFakeTask({\n        status: 'failed',\n        currentPhase: 'deliver',\n        prNumber: null,\n        prBranch: null,\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(failedTask);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(failedTask);\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalled();\n    });\n\n    it('restart on stuck task: resets all phases, enqueues refine', async () => {\n      const stuckTask = buildFakeTask({ status: 'stuck', prNumber: null });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(stuckTask);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(stuckTask);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n    });\n\n    it('restart with open PR: posts superseded comment, closes PR, clears prNumber/prBranch', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 77,\n        prBranch: 'feat/77-feature',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      // First postComment call should be the superseded comment on the PR\n      expect(platformProvider.postComment).toHaveBeenNthCalledWith(\n        1,\n        'AgentSDE',\n        'agent-core',\n        77,\n        expect.any(String),\n      );\n      expect(platformProvider.closePR).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        77,\n      );\n      expect(platformProvider.deleteBranch).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        'feat/77-feature',\n      );\n      expect(task.prNumber).toBeNull();\n      expect(task.prBranch).toBeNull();\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n    });\n\n    it('restart: PR close failure logs warning and proceeds', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 88,\n        prBranch: 'feat/88-thing',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n      (platformProvider.closePR as jest.Mock).mockRejectedValueOnce(\n        new Error('GitHub API error: 500'),\n      );\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'restart directive',\n      );\n    });\n\n    it('restart on active task: posts rejection comment, does not dispatch', async () => {\n      const activeTask = buildFakeTask({ status: 'active' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(activeTask);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForRestart).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      // rejection comment posted\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('restart does not overwrite task.directive', async () => {\n      const task = buildFakeTask({ directive: 'careful', status: 'blocked' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'restart',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(task.directive).toBe('careful');\n    });\n  });\n\n  describe('replan directive', () => {\n    it('normal replan: uses resetForReplan, closes PR, deletes branch, clears artefacts, enqueues refine', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        currentPhase: 'execute',\n        prNumber: 42,\n        prBranch: 'feat/42-my-feature',\n        replanAttempt: 0,\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n      (taskStateService.resetForReplan as jest.Mock).mockImplementation(\n        (t: TaskEntity): void => {\n          t.prNumber = null;\n          t.prBranch = null;\n          t.replanAttempt = (t.replanAttempt ?? 0) + 1;\n        },\n      );\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n      expect(taskStateService.resetForRestart).not.toHaveBeenCalled();\n      expect(taskStateService.save).toHaveBeenCalledTimes(1);\n      expect(platformProvider.closePR).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        42,\n      );\n      expect(platformProvider.deleteBranch).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        'feat/42-my-feature',\n      );\n      expect(artefactsService.clearPlanArtefacts).toHaveBeenCalledWith(\n        '/tmp/tasks/AgentSDE/agent-core/gh-18',\n      );\n      expect(eventService.record).toHaveBeenCalledWith(\n        task.id,\n        'replan_triggered',\n        'directive-service',\n        expect.objectContaining({ actor: 'zhjarvis', replan_attempt: 1 }),\n      );\n      expect(phaseHooksService.cleanupLabelsForReplan).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n      );\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'replan directive',\n      );\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalled();\n    });\n\n    it('replan with no PR: skips PR close/branch delete, still clears artefacts', async () => {\n      const task = buildFakeTask({\n        status: 'stuck',\n        currentPhase: 'plan',\n        prNumber: null,\n        prBranch: null,\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(platformProvider.closePR).not.toHaveBeenCalled();\n      expect(platformProvider.deleteBranch).not.toHaveBeenCalled();\n      expect(artefactsService.clearPlanArtefacts).toHaveBeenCalled();\n      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'replan directive',\n      );\n    });\n\n    it('replan already in active refine: posts no-op comment, does not enqueue', async () => {\n      const task = buildFakeTask({\n        status: 'active',\n        currentPhase: 'refine',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('replan on succeeded task: posts rejection comment, does not enqueue', async () => {\n      const task = buildFakeTask({ status: 'succeeded' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n      expect(dispatchService.dispatch).not.toHaveBeenCalled();\n      expect(platformProvider.postComment).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        18,\n        expect.any(String),\n      );\n    });\n\n    it('replan on failed task: posts rejection comment, does not enqueue', async () => {\n      const task = buildFakeTask({ status: 'failed' });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      expect(taskStateService.resetForReplan).not.toHaveBeenCalled();\n      expect(internalAdapterService.enqueuePhase).not.toHaveBeenCalled();\n    });\n\n    it('replan: PR close failure logs warning and proceeds', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 99,\n        prBranch: 'feat/99-branch',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n      (platformProvider.closePR as jest.Mock).mockRejectedValueOnce(\n        new Error('GitHub API error: 500 — Internal Server Error'),\n      );\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      // Should still reset and enqueue even when PR close fails\n      expect(taskStateService.resetForReplan).toHaveBeenCalledWith(task);\n      expect(internalAdapterService.enqueuePhase).toHaveBeenCalledWith(\n        18,\n        'AgentSDE/agent-core',\n        'refine',\n        'replan directive',\n      );\n    });\n\n    it('replan: posts superseded comment on open PR before closing', async () => {\n      const task = buildFakeTask({\n        status: 'blocked',\n        prNumber: 55,\n        prBranch: 'feat/55-thing',\n      });\n      taskStateService.findByIssueAndRepo.mockResolvedValue(task);\n\n      await service.applyDirective(\n        18,\n        'replan',\n        'AgentSDE/agent-core',\n        123,\n        'zhjarvis',\n      );\n\n      // First postComment call should be the superseded comment on the PR\n      expect(platformProvider.postComment).toHaveBeenNthCalledWith(\n        1,\n        'AgentSDE',\n        'agent-core',\n        55,\n        expect.any(String),\n      );\n      expect(platformProvider.closePR).toHaveBeenCalledWith(\n        'AgentSDE',\n        'agent-core',\n        55,\n      );\n    });\n  });\n});\n","structuredPatch":[{"oldStart":665,"oldLines":7,"newStart":665,"newLines":7,"lines":["     it('normal replan: uses resetForReplan, closes PR, deletes branch, clears artefacts, enqueues refine', async () => {","       const task = buildFakeTask({","         status: 'blocked',","-        currentPhase: 'execute',","+        currentPhase: 'deliver',","         prNumber: 42,","         prBranch: 'feat/42-my-feature',","         replanAttempt: 0,"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01Q6niJdXYsYWczqimm56d5N","type":"message","role":"assistant","content":[{"type":"text","text":"Now update phase-hooks.service.spec.ts:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":347,"cache_read_input_tokens":104336,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":347},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"a3fcc65c-ef87-4222-9d44-0b71f2d057c0"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01Q6niJdXYsYWczqimm56d5N","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SSFFpDNyRrnPv2zqX8ixd7","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/hooks/phase-hooks.service.spec.ts","old_string":"      await service.onPhaseBlocked(\n        baseTask,\n        'execute',\n        'missing context',\n        'blocked',\n      );\n\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'agent-blocked',\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('🚫 Phase persistently blocked'),\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('execute'),\n      );","new_string":"      await service.onPhaseBlocked(\n        baseTask,\n        'deliver',\n        'missing context',\n        'blocked',\n      );\n\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'agent-blocked',\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('🚫 Phase persistently blocked'),\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('deliver'),\n      );"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":347,"cache_read_input_tokens":104336,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":347},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"5c66b1dc-6338-4c38-a0b5-e30e057123bb"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SSFFpDNyRrnPv2zqX8ixd7","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/hooks/phase-hooks.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"a0c382ae-9ef8-4298-b04c-ed8fc1a89d28","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/hooks/phase-hooks.service.spec.ts","oldString":"      await service.onPhaseBlocked(\n        baseTask,\n        'execute',\n        'missing context',\n        'blocked',\n      );\n\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'agent-blocked',\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('🚫 Phase persistently blocked'),\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('execute'),\n      );","newString":"      await service.onPhaseBlocked(\n        baseTask,\n        'deliver',\n        'missing context',\n        'blocked',\n      );\n\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'agent-blocked',\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('🚫 Phase persistently blocked'),\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('deliver'),\n      );","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { ConfigService } from '@nestjs/config';\nimport { PhaseHooksService, PhaseTask } from './phase-hooks.service';\nimport { GitHubService } from '../github';\nimport { CompoundService } from './compound.service';\nimport { EventService } from '../event/event.service';\nimport { ReleaseService } from '../release/release.service';\n\ndescribe('PhaseHooksService', () => {\n  let module: TestingModule;\n  let service: PhaseHooksService;\n  let github: jest.Mocked<GitHubService>;\n  let compound: jest.Mocked<CompoundService>;\n  let releaseService: jest.Mocked<ReleaseService>;\n\n  const baseTask: PhaseTask = {\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n  };\n\n  beforeEach(async () => {\n    module = await Test.createTestingModule({\n      providers: [\n        PhaseHooksService,\n        {\n          provide: GitHubService,\n          useValue: {\n            addLabel: jest.fn().mockResolvedValue(undefined),\n            removeLabel: jest.fn().mockResolvedValue(undefined),\n            postComment: jest.fn().mockResolvedValue(undefined),\n            editComment: jest.fn().mockResolvedValue(undefined),\n            mergePR: jest.fn().mockResolvedValue(undefined),\n            closeIssue: jest.fn().mockResolvedValue(undefined),\n            getCheckStatus: jest\n              .fn()\n              .mockResolvedValue({ passing: true, noChecks: false }),\n            getReviewStatus: jest.fn().mockResolvedValue({ approved: true }),\n            setProjectItemStatus: jest.fn().mockResolvedValue(undefined),\n            findCommentByPrefix: jest.fn().mockResolvedValue(null),\n          },\n        },\n        {\n          provide: ConfigService,\n          useValue: {\n            get: jest.fn(),\n          },\n        },\n        {\n          provide: CompoundService,\n          useValue: {\n            writeLearnings: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get<PhaseHooksService>(PhaseHooksService);\n    github = module.get(GitHubService);\n    compound = module.get(CompoundService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  describe('refine phase', () => {\n    it('removes in-refinement label and adds refined label', async () => {\n      await service.onPhaseComplete(baseTask, 'refine');\n\n      expect(github.removeLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'in-refinement',\n      );\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'refined',\n      );\n    });\n\n    it('does not throw when label removal fails', async () => {\n      github.removeLabel.mockRejectedValueOnce(new Error('Not Found'));\n\n      await expect(\n        service.onPhaseComplete(baseTask, 'refine'),\n      ).resolves.toBeUndefined();\n\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'refined',\n      );\n    });\n\n    it('moves issue to AgentQueue on project board when PROJECT_ID is set', async () => {\n      const config = module.get(ConfigService);\n      (config.get as jest.Mock).mockReturnValue('PVT_test123');\n\n      await service.onPhaseComplete(baseTask, 'refine');\n\n      expect(github.setProjectItemStatus).toHaveBeenCalledWith(\n        'PVT_test123',\n        'org',\n        'repo',\n        42,\n        'AgentQueue',\n      );\n    });\n\n    it('does not throw when project board update fails', async () => {\n      const config = module.get(ConfigService);\n      (config.get as jest.Mock).mockReturnValue('PVT_test123');\n      github.setProjectItemStatus.mockRejectedValueOnce(\n        new Error('GraphQL error'),\n      );\n\n      await expect(\n        service.onPhaseComplete(baseTask, 'refine'),\n      ).resolves.toBeUndefined();\n    });\n\n    it('skips project board update when PROJECT_ID is not set', async () => {\n      const config = module.get(ConfigService);\n      (config.get as jest.Mock).mockReturnValue(undefined);\n\n      await service.onPhaseComplete(baseTask, 'refine');\n\n      expect(github.setProjectItemStatus).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('plan phase', () => {\n    it('posts a plan complete comment when no active release', async () => {\n      await service.onPhaseComplete(baseTask, 'plan');\n\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Plan phase complete.** Technical plan is ready.',\n      );\n    });\n\n    it('posts simple comment when taskId is undefined', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: undefined };\n\n      await service.onPhaseComplete(task, 'plan');\n\n      expect(releaseService.findActiveForTask).not.toHaveBeenCalled();\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Plan phase complete.** Technical plan is ready.',\n      );\n    });\n\n    it('edits plan comment with release context when release is active and plan comment exists', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: 10 };\n      (releaseService.findActiveForTask as jest.Mock).mockResolvedValueOnce({\n        releaseId: 1,\n        key: 'v2.0',\n        title: 'Version 2.0',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v2.0',\n        baseBranch: 'master',\n      });\n      (github.findCommentByPrefix as jest.Mock).mockResolvedValueOnce({\n        id: 555,\n        body: '## Agent Plan\\n\\nSome plan content.',\n      });\n\n      await service.onPhaseComplete(task, 'plan');\n\n      expect(releaseService.findActiveForTask).toHaveBeenCalledWith(10);\n      expect(github.findCommentByPrefix).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '## Agent Plan',\n      );\n      expect(github.editComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        555,\n        expect.stringContaining('## Release Context'),\n      );\n      expect(github.editComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        555,\n        expect.stringContaining('v2.0'),\n      );\n      expect(github.postComment).not.toHaveBeenCalled();\n    });\n\n    it('posts standalone release context comment when plan comment is not found', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: 10 };\n      (releaseService.findActiveForTask as jest.Mock).mockResolvedValueOnce({\n        releaseId: 1,\n        key: 'v2.0',\n        title: 'Version 2.0',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v2.0',\n        baseBranch: 'master',\n      });\n      // findCommentByPrefix already returns null by default\n\n      await service.onPhaseComplete(task, 'plan');\n\n      expect(github.editComment).not.toHaveBeenCalled();\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('## Release Context'),\n      );\n    });\n\n    it('falls back to simple comment when releaseService throws', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: 10 };\n      (releaseService.findActiveForTask as jest.Mock).mockRejectedValueOnce(\n        new Error('DB error'),\n      );\n\n      await service.onPhaseComplete(task, 'plan');\n\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Plan phase complete.** Technical plan is ready.',\n      );\n    });\n  });\n\n  describe('deliver phase', () => {\n    it('adds in-review label and posts PR comment when PR exists', async () => {\n      const task: PhaseTask = { ...baseTask, prNumber: 99 };\n      await service.onPhaseComplete(task, 'deliver');\n\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'in-review',\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '🚀 PR created: https://github.com/org/repo/pull/99',\n      );\n    });\n\n    it('does not post comment or add label when no PR exists', async () => {\n      await expect(\n        service.onPhaseComplete(baseTask, 'deliver'),\n      ).resolves.toBeUndefined();\n\n      expect(github.addLabel).not.toHaveBeenCalled();\n      expect(github.postComment).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('review phase', () => {\n    it('merges PR when auto directive and checks pass and review approved', async () => {\n      const task: PhaseTask = {\n        ...baseTask,\n        prNumber: 10,\n        prBranch: 'feat/test',\n      };\n\n      await service.onPhaseComplete(task, 'review');\n\n      expect(github.getCheckStatus).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        'feat/test',\n      );\n      expect(github.getReviewStatus).toHaveBeenCalledWith('org', 'repo', 10);\n      expect(github.mergePR).toHaveBeenCalledWith('org', 'repo', 10);\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('does not merge PR when directive is careful', async () => {\n      const task: PhaseTask = {\n        ...baseTask,\n        directive: 'careful',\n        prNumber: 10,\n        prBranch: 'feat/test',\n      };\n\n      await service.onPhaseComplete(task, 'review');\n\n      expect(github.mergePR).not.toHaveBeenCalled();\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('does not merge PR when checks are not passing', async () => {\n      github.getCheckStatus.mockResolvedValueOnce({\n        passing: false,\n        noChecks: false,\n      });\n\n      const task: PhaseTask = {\n        ...baseTask,\n        prNumber: 10,\n        prBranch: 'feat/test',\n      };\n\n      await service.onPhaseComplete(task, 'review');\n\n      expect(github.mergePR).not.toHaveBeenCalled();\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('does not merge PR when review is not approved', async () => {\n      github.getReviewStatus.mockResolvedValueOnce({ approved: false });\n\n      const task: PhaseTask = {\n        ...baseTask,\n        prNumber: 10,\n        prBranch: 'feat/test',\n      };\n\n      await service.onPhaseComplete(task, 'review');\n\n      expect(github.mergePR).not.toHaveBeenCalled();\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('closes issue even when no PR exists', async () => {\n      await service.onPhaseComplete(baseTask, 'review');\n\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('skips auto-merge and closes issue when prBranch is null', async () => {\n      const task: PhaseTask = {\n        ...baseTask,\n        prNumber: 10,\n        prBranch: undefined,\n      };\n\n      await service.onPhaseComplete(task, 'review');\n\n      expect(github.getCheckStatus).not.toHaveBeenCalled();\n      expect(github.mergePR).not.toHaveBeenCalled();\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n  });\n\n  describe('compound phase', () => {\n    it('calls writeLearnings and posts completion comment', async () => {\n      await service.onPhaseComplete(baseTask, 'compound');\n\n      expect(compound.writeLearnings).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        baseTask,\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Task complete.** All phases finished successfully.',\n      );\n    });\n\n    it('still posts completion comment when writeLearnings fails', async () => {\n      compound.writeLearnings.mockRejectedValueOnce(new Error('API error'));\n\n      await service.onPhaseComplete(baseTask, 'compound');\n\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Task complete.** All phases finished successfully.',\n      );\n    });\n  });\n\n  describe('onPhaseSkip', () => {\n    it('closes the issue when deliver phase is skipped', async () => {\n      await service.onPhaseSkip(baseTask, 'deliver', 'no changes needed');\n\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('closes the issue with default reason when none provided', async () => {\n      await service.onPhaseSkip(baseTask, 'deliver');\n\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('closes the issue when plan phase is skipped', async () => {\n      await service.onPhaseSkip(baseTask, 'plan', 'no code changes needed');\n\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('does not close the issue for non-plan/deliver phases', async () => {\n      await service.onPhaseSkip(baseTask, 'refine', 'nothing to refine');\n\n      expect(github.closeIssue).not.toHaveBeenCalled();\n    });\n\n    it('catches and logs errors when closeIssue fails on skip', async () => {\n      github.closeIssue.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseSkip(baseTask, 'deliver', 'no changes needed'),\n      ).resolves.toBeUndefined();\n    });\n\n    it('calls writeLearnings and posts completion comment when compound is skipped', async () => {\n      await service.onPhaseSkip(baseTask, 'compound', 'no learnings');\n\n      expect(compound.writeLearnings).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        baseTask,\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Task complete.** All phases finished successfully.',\n      );\n    });\n\n    it('still posts completion comment when writeLearnings fails on compound skip', async () => {\n      compound.writeLearnings.mockRejectedValueOnce(new Error('API error'));\n\n      await service.onPhaseSkip(baseTask, 'compound', 'no learnings');\n\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Task complete.** All phases finished successfully.',\n      );\n    });\n  });\n\n  describe('onPhaseBlocked', () => {\n    it('adds agent-blocked label and posts diagnostic comment for blocked category', async () => {\n      await service.onPhaseBlocked(\n        baseTask,\n        'execute',\n        'missing context',\n        'blocked',\n      );\n\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'agent-blocked',\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('🚫 Phase persistently blocked'),\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('execute'),\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('missing context'),\n      );\n    });\n\n    it('adds agent-blocked label and posts comment for gated category on non-refine/review phase', async () => {\n      await service.onPhaseBlocked(\n        baseTask,\n        'plan',\n        'quality gate not passed',\n        'gated',\n      );\n\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'agent-blocked',\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('🚫 Phase persistently blocked'),\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('quality gate not passed'),\n      );\n    });\n\n    it('suppresses label and comment for gated category on refine phase', async () => {\n      await service.onPhaseBlocked(\n        baseTask,\n        'refine',\n        'quality gate not passed',\n        'gated',\n      );\n\n      expect(github.addLabel).not.toHaveBeenCalled();\n      expect(github.postComment).not.toHaveBeenCalled();\n    });\n\n    it('suppresses label and comment for gated category on review phase', async () => {\n      await service.onPhaseBlocked(\n        baseTask,\n        'review',\n        'quality gate not passed',\n        'gated',\n      );\n\n      expect(github.addLabel).not.toHaveBeenCalled();\n      expect(github.postComment).not.toHaveBeenCalled();\n    });\n\n    it('uses stuck label text for stuck category', async () => {\n      await service.onPhaseBlocked(\n        baseTask,\n        'plan',\n        'no progress for 30min',\n        'stuck',\n      );\n\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('🔴 Task stuck'),\n      );\n    });\n\n    it('uses timeout label text for timeout category', async () => {\n      await service.onPhaseBlocked(baseTask, 'refine', 'timed out', 'timeout');\n\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('⏱️ Phase timed out'),\n      );\n    });\n\n    it('does not throw when addLabel fails', async () => {\n      github.addLabel.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseBlocked(baseTask, 'execute', 'blocked', 'blocked'),\n      ).resolves.toBeUndefined();\n\n      expect(github.postComment).toHaveBeenCalled();\n    });\n\n    it('does not throw when postComment fails', async () => {\n      github.postComment.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseBlocked(baseTask, 'execute', 'blocked', 'blocked'),\n      ).resolves.toBeUndefined();\n    });\n  });\n\n  describe('unknown phase', () => {\n    it('does not throw for unknown phase', async () => {\n      await expect(\n        service.onPhaseComplete(baseTask, 'unknown-phase'),\n      ).resolves.toBeUndefined();\n    });\n  });\n\n  describe('onPhaseStart', () => {\n    it('adds in-refinement label when refine phase starts', async () => {\n      await service.onPhaseStart(baseTask, 'refine');\n\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'in-refinement',\n      );\n    });\n\n    it('does not call addLabel for non-refine phases', async () => {\n      await service.onPhaseStart(baseTask, 'plan');\n      await service.onPhaseStart(baseTask, 'deliver');\n      await service.onPhaseStart(baseTask, 'review');\n\n      expect(github.addLabel).not.toHaveBeenCalled();\n    });\n\n    it('catches addLabel failure and logs warning without throwing', async () => {\n      github.addLabel.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseStart(baseTask, 'refine'),\n      ).resolves.toBeUndefined();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    let eventService: jest.Mocked<EventService>;\n\n    beforeEach(() => {\n      eventService = module.get(EventService);\n    });\n\n    describe('onPhaseBlocked() dedup', () => {\n      it('posts blocked comment on first call and records dedup event', async () => {\n        const task = { ...baseTask, taskId: 10 };\n        (eventService.countByType as jest.Mock).mockResolvedValueOnce(0);\n\n        await service.onPhaseBlocked(task, 'plan', 'lint failed', 'blocked');\n\n        expect(eventService.record).toHaveBeenCalledWith(\n          10,\n          'phase_blocked_comment_posted:plan',\n          'phase-hooks',\n          { phase: 'plan', category: 'blocked' },\n        );\n        expect(github.addLabel).toHaveBeenCalledWith(\n          'org',\n          'repo',\n          42,\n          'agent-blocked',\n        );\n        expect(github.postComment).toHaveBeenCalledWith(\n          'org',\n          'repo',\n          42,\n          expect.stringContaining('lint failed'),\n        );\n      });\n\n      it('skips duplicate blocked comment when dedup event already recorded', async () => {\n        const task = { ...baseTask, taskId: 10 };\n        (eventService.countByType as jest.Mock).mockResolvedValueOnce(1); // already posted\n\n        await service.onPhaseBlocked(task, 'plan', 'lint failed', 'blocked');\n\n        expect(github.addLabel).not.toHaveBeenCalled();\n        expect(github.postComment).not.toHaveBeenCalled();\n      });\n\n      it('checks dedup event key scoped to the phase', async () => {\n        const task = { ...baseTask, taskId: 10 };\n        (eventService.countByType as jest.Mock).mockResolvedValueOnce(0);\n\n        await service.onPhaseBlocked(task, 'deliver', 'push failed', 'blocked');\n\n        expect(eventService.countByType).toHaveBeenCalledWith(\n          10,\n          'phase_blocked_comment_posted:deliver',\n        );\n      });\n\n      it('proceeds without dedup guard when taskId is undefined', async () => {\n        // Tasks without a taskId get no event-based dedup — falls back to always posting\n        await service.onPhaseBlocked(baseTask, 'plan', 'reason', 'blocked');\n\n        expect(eventService.countByType).not.toHaveBeenCalled();\n        expect(github.postComment).toHaveBeenCalled();\n      });\n\n      it('proceeds to post if countByType rejects (fail-open)', async () => {\n        const task = { ...baseTask, taskId: 10 };\n        (eventService.countByType as jest.Mock).mockRejectedValueOnce(\n          new Error('DB error'),\n        );\n\n        await service.onPhaseBlocked(task, 'plan', 'reason', 'blocked');\n\n        // countByType rejected → treated as 0 → proceeds normally\n        expect(github.postComment).toHaveBeenCalled();\n      });\n    });\n\n    describe('onCompoundComplete() dedup', () => {\n      it('posts completion comment on first call and records dedup event', async () => {\n        const task = { ...baseTask, taskId: 20 };\n        (eventService.countByType as jest.Mock).mockResolvedValueOnce(0);\n\n        await service.onPhaseComplete(task, 'compound');\n\n        expect(eventService.record).toHaveBeenCalledWith(\n          20,\n          'compound_complete_comment_posted',\n          'phase-hooks',\n          {},\n        );\n        expect(github.postComment).toHaveBeenCalledWith(\n          'org',\n          'repo',\n          42,\n          '**Task complete.** All phases finished successfully.',\n        );\n      });\n\n      it('skips duplicate completion comment when dedup event already recorded', async () => {\n        const task = { ...baseTask, taskId: 20 };\n        (eventService.countByType as jest.Mock).mockResolvedValueOnce(1); // already posted\n\n        await service.onPhaseComplete(task, 'compound');\n\n        expect(compound.writeLearnings).not.toHaveBeenCalled();\n        expect(github.postComment).not.toHaveBeenCalled();\n      });\n\n      it('still posts when taskId is undefined (no dedup guard)', async () => {\n        await service.onPhaseComplete(baseTask, 'compound');\n\n        expect(eventService.countByType).not.toHaveBeenCalledWith(\n          expect.anything(),\n          'compound_complete_comment_posted',\n        );\n        expect(github.postComment).toHaveBeenCalledWith(\n          'org',\n          'repo',\n          42,\n          '**Task complete.** All phases finished successfully.',\n        );\n      });\n    });\n  });\n});\n","structuredPatch":[{"oldStart":471,"oldLines":7,"newStart":471,"newLines":7,"lines":["     it('adds agent-blocked label and posts diagnostic comment for blocked category', async () => {","       await service.onPhaseBlocked(","         baseTask,","-        'execute',","+        'deliver',","         'missing context',","         'blocked',","       );"]},{"oldStart":492,"oldLines":7,"newStart":492,"newLines":7,"lines":["         'org',","         'repo',","         42,","-        expect.stringContaining('execute'),","+        expect.stringContaining('deliver'),","       );","       expect(github.postComment).toHaveBeenCalledWith(","         'org',"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01RzJGWE2Xqtx4XUDsapsSh7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015zQjxtyWeSaP19VJWpRqSG","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/hooks/phase-hooks.service.spec.ts","old_string":"    it('does not throw when addLabel fails', async () => {\n      github.addLabel.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseBlocked(baseTask, 'execute', 'blocked', 'blocked'),\n      ).resolves.toBeUndefined();\n\n      expect(github.postComment).toHaveBeenCalled();\n    });\n\n    it('does not throw when postComment fails', async () => {\n      github.postComment.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseBlocked(baseTask, 'execute', 'blocked', 'blocked'),\n      ).resolves.toBeUndefined();\n    });","new_string":"    it('does not throw when addLabel fails', async () => {\n      github.addLabel.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseBlocked(baseTask, 'deliver', 'blocked', 'blocked'),\n      ).resolves.toBeUndefined();\n\n      expect(github.postComment).toHaveBeenCalled();\n    });\n\n    it('does not throw when postComment fails', async () => {\n      github.postComment.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseBlocked(baseTask, 'deliver', 'blocked', 'blocked'),\n      ).resolves.toBeUndefined();\n    });"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":566,"cache_read_input_tokens":104683,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":566},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"1d3646b0-bd12-442d-828f-5ad95b7cbb31"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015zQjxtyWeSaP19VJWpRqSG","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/hooks/phase-hooks.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"13a1fe3e-7f15-4690-9491-00ab38e513d9","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/hooks/phase-hooks.service.spec.ts","oldString":"    it('does not throw when addLabel fails', async () => {\n      github.addLabel.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseBlocked(baseTask, 'execute', 'blocked', 'blocked'),\n      ).resolves.toBeUndefined();\n\n      expect(github.postComment).toHaveBeenCalled();\n    });\n\n    it('does not throw when postComment fails', async () => {\n      github.postComment.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseBlocked(baseTask, 'execute', 'blocked', 'blocked'),\n      ).resolves.toBeUndefined();\n    });","newString":"    it('does not throw when addLabel fails', async () => {\n      github.addLabel.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseBlocked(baseTask, 'deliver', 'blocked', 'blocked'),\n      ).resolves.toBeUndefined();\n\n      expect(github.postComment).toHaveBeenCalled();\n    });\n\n    it('does not throw when postComment fails', async () => {\n      github.postComment.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseBlocked(baseTask, 'deliver', 'blocked', 'blocked'),\n      ).resolves.toBeUndefined();\n    });","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { ConfigService } from '@nestjs/config';\nimport { PhaseHooksService, PhaseTask } from './phase-hooks.service';\nimport { GitHubService } from '../github';\nimport { CompoundService } from './compound.service';\nimport { EventService } from '../event/event.service';\nimport { ReleaseService } from '../release/release.service';\n\ndescribe('PhaseHooksService', () => {\n  let module: TestingModule;\n  let service: PhaseHooksService;\n  let github: jest.Mocked<GitHubService>;\n  let compound: jest.Mocked<CompoundService>;\n  let releaseService: jest.Mocked<ReleaseService>;\n\n  const baseTask: PhaseTask = {\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n  };\n\n  beforeEach(async () => {\n    module = await Test.createTestingModule({\n      providers: [\n        PhaseHooksService,\n        {\n          provide: GitHubService,\n          useValue: {\n            addLabel: jest.fn().mockResolvedValue(undefined),\n            removeLabel: jest.fn().mockResolvedValue(undefined),\n            postComment: jest.fn().mockResolvedValue(undefined),\n            editComment: jest.fn().mockResolvedValue(undefined),\n            mergePR: jest.fn().mockResolvedValue(undefined),\n            closeIssue: jest.fn().mockResolvedValue(undefined),\n            getCheckStatus: jest\n              .fn()\n              .mockResolvedValue({ passing: true, noChecks: false }),\n            getReviewStatus: jest.fn().mockResolvedValue({ approved: true }),\n            setProjectItemStatus: jest.fn().mockResolvedValue(undefined),\n            findCommentByPrefix: jest.fn().mockResolvedValue(null),\n          },\n        },\n        {\n          provide: ConfigService,\n          useValue: {\n            get: jest.fn(),\n          },\n        },\n        {\n          provide: CompoundService,\n          useValue: {\n            writeLearnings: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn().mockResolvedValue(undefined),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: ReleaseService,\n          useValue: {\n            findActiveForTask: jest.fn().mockResolvedValue(null),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get<PhaseHooksService>(PhaseHooksService);\n    github = module.get(GitHubService);\n    compound = module.get(CompoundService);\n    releaseService = module.get(ReleaseService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  describe('refine phase', () => {\n    it('removes in-refinement label and adds refined label', async () => {\n      await service.onPhaseComplete(baseTask, 'refine');\n\n      expect(github.removeLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'in-refinement',\n      );\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'refined',\n      );\n    });\n\n    it('does not throw when label removal fails', async () => {\n      github.removeLabel.mockRejectedValueOnce(new Error('Not Found'));\n\n      await expect(\n        service.onPhaseComplete(baseTask, 'refine'),\n      ).resolves.toBeUndefined();\n\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'refined',\n      );\n    });\n\n    it('moves issue to AgentQueue on project board when PROJECT_ID is set', async () => {\n      const config = module.get(ConfigService);\n      (config.get as jest.Mock).mockReturnValue('PVT_test123');\n\n      await service.onPhaseComplete(baseTask, 'refine');\n\n      expect(github.setProjectItemStatus).toHaveBeenCalledWith(\n        'PVT_test123',\n        'org',\n        'repo',\n        42,\n        'AgentQueue',\n      );\n    });\n\n    it('does not throw when project board update fails', async () => {\n      const config = module.get(ConfigService);\n      (config.get as jest.Mock).mockReturnValue('PVT_test123');\n      github.setProjectItemStatus.mockRejectedValueOnce(\n        new Error('GraphQL error'),\n      );\n\n      await expect(\n        service.onPhaseComplete(baseTask, 'refine'),\n      ).resolves.toBeUndefined();\n    });\n\n    it('skips project board update when PROJECT_ID is not set', async () => {\n      const config = module.get(ConfigService);\n      (config.get as jest.Mock).mockReturnValue(undefined);\n\n      await service.onPhaseComplete(baseTask, 'refine');\n\n      expect(github.setProjectItemStatus).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('plan phase', () => {\n    it('posts a plan complete comment when no active release', async () => {\n      await service.onPhaseComplete(baseTask, 'plan');\n\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Plan phase complete.** Technical plan is ready.',\n      );\n    });\n\n    it('posts simple comment when taskId is undefined', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: undefined };\n\n      await service.onPhaseComplete(task, 'plan');\n\n      expect(releaseService.findActiveForTask).not.toHaveBeenCalled();\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Plan phase complete.** Technical plan is ready.',\n      );\n    });\n\n    it('edits plan comment with release context when release is active and plan comment exists', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: 10 };\n      (releaseService.findActiveForTask as jest.Mock).mockResolvedValueOnce({\n        releaseId: 1,\n        key: 'v2.0',\n        title: 'Version 2.0',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v2.0',\n        baseBranch: 'master',\n      });\n      (github.findCommentByPrefix as jest.Mock).mockResolvedValueOnce({\n        id: 555,\n        body: '## Agent Plan\\n\\nSome plan content.',\n      });\n\n      await service.onPhaseComplete(task, 'plan');\n\n      expect(releaseService.findActiveForTask).toHaveBeenCalledWith(10);\n      expect(github.findCommentByPrefix).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '## Agent Plan',\n      );\n      expect(github.editComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        555,\n        expect.stringContaining('## Release Context'),\n      );\n      expect(github.editComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        555,\n        expect.stringContaining('v2.0'),\n      );\n      expect(github.postComment).not.toHaveBeenCalled();\n    });\n\n    it('posts standalone release context comment when plan comment is not found', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: 10 };\n      (releaseService.findActiveForTask as jest.Mock).mockResolvedValueOnce({\n        releaseId: 1,\n        key: 'v2.0',\n        title: 'Version 2.0',\n        status: 'active',\n        directive: 'auto',\n        rcBranch: 'rc/v2.0',\n        baseBranch: 'master',\n      });\n      // findCommentByPrefix already returns null by default\n\n      await service.onPhaseComplete(task, 'plan');\n\n      expect(github.editComment).not.toHaveBeenCalled();\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('## Release Context'),\n      );\n    });\n\n    it('falls back to simple comment when releaseService throws', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: 10 };\n      (releaseService.findActiveForTask as jest.Mock).mockRejectedValueOnce(\n        new Error('DB error'),\n      );\n\n      await service.onPhaseComplete(task, 'plan');\n\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Plan phase complete.** Technical plan is ready.',\n      );\n    });\n  });\n\n  describe('deliver phase', () => {\n    it('adds in-review label and posts PR comment when PR exists', async () => {\n      const task: PhaseTask = { ...baseTask, prNumber: 99 };\n      await service.onPhaseComplete(task, 'deliver');\n\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'in-review',\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '🚀 PR created: https://github.com/org/repo/pull/99',\n      );\n    });\n\n    it('does not post comment or add label when no PR exists', async () => {\n      await expect(\n        service.onPhaseComplete(baseTask, 'deliver'),\n      ).resolves.toBeUndefined();\n\n      expect(github.addLabel).not.toHaveBeenCalled();\n      expect(github.postComment).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('review phase', () => {\n    it('merges PR when auto directive and checks pass and review approved', async () => {\n      const task: PhaseTask = {\n        ...baseTask,\n        prNumber: 10,\n        prBranch: 'feat/test',\n      };\n\n      await service.onPhaseComplete(task, 'review');\n\n      expect(github.getCheckStatus).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        'feat/test',\n      );\n      expect(github.getReviewStatus).toHaveBeenCalledWith('org', 'repo', 10);\n      expect(github.mergePR).toHaveBeenCalledWith('org', 'repo', 10);\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('does not merge PR when directive is careful', async () => {\n      const task: PhaseTask = {\n        ...baseTask,\n        directive: 'careful',\n        prNumber: 10,\n        prBranch: 'feat/test',\n      };\n\n      await service.onPhaseComplete(task, 'review');\n\n      expect(github.mergePR).not.toHaveBeenCalled();\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('does not merge PR when checks are not passing', async () => {\n      github.getCheckStatus.mockResolvedValueOnce({\n        passing: false,\n        noChecks: false,\n      });\n\n      const task: PhaseTask = {\n        ...baseTask,\n        prNumber: 10,\n        prBranch: 'feat/test',\n      };\n\n      await service.onPhaseComplete(task, 'review');\n\n      expect(github.mergePR).not.toHaveBeenCalled();\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('does not merge PR when review is not approved', async () => {\n      github.getReviewStatus.mockResolvedValueOnce({ approved: false });\n\n      const task: PhaseTask = {\n        ...baseTask,\n        prNumber: 10,\n        prBranch: 'feat/test',\n      };\n\n      await service.onPhaseComplete(task, 'review');\n\n      expect(github.mergePR).not.toHaveBeenCalled();\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('closes issue even when no PR exists', async () => {\n      await service.onPhaseComplete(baseTask, 'review');\n\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('skips auto-merge and closes issue when prBranch is null', async () => {\n      const task: PhaseTask = {\n        ...baseTask,\n        prNumber: 10,\n        prBranch: undefined,\n      };\n\n      await service.onPhaseComplete(task, 'review');\n\n      expect(github.getCheckStatus).not.toHaveBeenCalled();\n      expect(github.mergePR).not.toHaveBeenCalled();\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n  });\n\n  describe('compound phase', () => {\n    it('calls writeLearnings and posts completion comment', async () => {\n      await service.onPhaseComplete(baseTask, 'compound');\n\n      expect(compound.writeLearnings).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        baseTask,\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Task complete.** All phases finished successfully.',\n      );\n    });\n\n    it('still posts completion comment when writeLearnings fails', async () => {\n      compound.writeLearnings.mockRejectedValueOnce(new Error('API error'));\n\n      await service.onPhaseComplete(baseTask, 'compound');\n\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Task complete.** All phases finished successfully.',\n      );\n    });\n  });\n\n  describe('onPhaseSkip', () => {\n    it('closes the issue when deliver phase is skipped', async () => {\n      await service.onPhaseSkip(baseTask, 'deliver', 'no changes needed');\n\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('closes the issue with default reason when none provided', async () => {\n      await service.onPhaseSkip(baseTask, 'deliver');\n\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('closes the issue when plan phase is skipped', async () => {\n      await service.onPhaseSkip(baseTask, 'plan', 'no code changes needed');\n\n      expect(github.closeIssue).toHaveBeenCalledWith('org', 'repo', 42);\n    });\n\n    it('does not close the issue for non-plan/deliver phases', async () => {\n      await service.onPhaseSkip(baseTask, 'refine', 'nothing to refine');\n\n      expect(github.closeIssue).not.toHaveBeenCalled();\n    });\n\n    it('catches and logs errors when closeIssue fails on skip', async () => {\n      github.closeIssue.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseSkip(baseTask, 'deliver', 'no changes needed'),\n      ).resolves.toBeUndefined();\n    });\n\n    it('calls writeLearnings and posts completion comment when compound is skipped', async () => {\n      await service.onPhaseSkip(baseTask, 'compound', 'no learnings');\n\n      expect(compound.writeLearnings).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        baseTask,\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Task complete.** All phases finished successfully.',\n      );\n    });\n\n    it('still posts completion comment when writeLearnings fails on compound skip', async () => {\n      compound.writeLearnings.mockRejectedValueOnce(new Error('API error'));\n\n      await service.onPhaseSkip(baseTask, 'compound', 'no learnings');\n\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        '**Task complete.** All phases finished successfully.',\n      );\n    });\n  });\n\n  describe('onPhaseBlocked', () => {\n    it('adds agent-blocked label and posts diagnostic comment for blocked category', async () => {\n      await service.onPhaseBlocked(\n        baseTask,\n        'deliver',\n        'missing context',\n        'blocked',\n      );\n\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'agent-blocked',\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('🚫 Phase persistently blocked'),\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('deliver'),\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('missing context'),\n      );\n    });\n\n    it('adds agent-blocked label and posts comment for gated category on non-refine/review phase', async () => {\n      await service.onPhaseBlocked(\n        baseTask,\n        'plan',\n        'quality gate not passed',\n        'gated',\n      );\n\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'agent-blocked',\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('🚫 Phase persistently blocked'),\n      );\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('quality gate not passed'),\n      );\n    });\n\n    it('suppresses label and comment for gated category on refine phase', async () => {\n      await service.onPhaseBlocked(\n        baseTask,\n        'refine',\n        'quality gate not passed',\n        'gated',\n      );\n\n      expect(github.addLabel).not.toHaveBeenCalled();\n      expect(github.postComment).not.toHaveBeenCalled();\n    });\n\n    it('suppresses label and comment for gated category on review phase', async () => {\n      await service.onPhaseBlocked(\n        baseTask,\n        'review',\n        'quality gate not passed',\n        'gated',\n      );\n\n      expect(github.addLabel).not.toHaveBeenCalled();\n      expect(github.postComment).not.toHaveBeenCalled();\n    });\n\n    it('uses stuck label text for stuck category', async () => {\n      await service.onPhaseBlocked(\n        baseTask,\n        'plan',\n        'no progress for 30min',\n        'stuck',\n      );\n\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('🔴 Task stuck'),\n      );\n    });\n\n    it('uses timeout label text for timeout category', async () => {\n      await service.onPhaseBlocked(baseTask, 'refine', 'timed out', 'timeout');\n\n      expect(github.postComment).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        expect.stringContaining('⏱️ Phase timed out'),\n      );\n    });\n\n    it('does not throw when addLabel fails', async () => {\n      github.addLabel.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseBlocked(baseTask, 'execute', 'blocked', 'blocked'),\n      ).resolves.toBeUndefined();\n\n      expect(github.postComment).toHaveBeenCalled();\n    });\n\n    it('does not throw when postComment fails', async () => {\n      github.postComment.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseBlocked(baseTask, 'execute', 'blocked', 'blocked'),\n      ).resolves.toBeUndefined();\n    });\n  });\n\n  describe('unknown phase', () => {\n    it('does not throw for unknown phase', async () => {\n      await expect(\n        service.onPhaseComplete(baseTask, 'unknown-phase'),\n      ).resolves.toBeUndefined();\n    });\n  });\n\n  describe('onPhaseStart', () => {\n    it('adds in-refinement label when refine phase starts', async () => {\n      await service.onPhaseStart(baseTask, 'refine');\n\n      expect(github.addLabel).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        42,\n        'in-refinement',\n      );\n    });\n\n    it('does not call addLabel for non-refine phases', async () => {\n      await service.onPhaseStart(baseTask, 'plan');\n      await service.onPhaseStart(baseTask, 'deliver');\n      await service.onPhaseStart(baseTask, 'review');\n\n      expect(github.addLabel).not.toHaveBeenCalled();\n    });\n\n    it('catches addLabel failure and logs warning without throwing', async () => {\n      github.addLabel.mockRejectedValueOnce(new Error('API error'));\n\n      await expect(\n        service.onPhaseStart(baseTask, 'refine'),\n      ).resolves.toBeUndefined();\n    });\n  });\n\n  describe('BullMQ retry idempotency', () => {\n    let eventService: jest.Mocked<EventService>;\n\n    beforeEach(() => {\n      eventService = module.get(EventService);\n    });\n\n    describe('onPhaseBlocked() dedup', () => {\n      it('posts blocked comment on first call and records dedup event', async () => {\n        const task = { ...baseTask, taskId: 10 };\n        (eventService.countByType as jest.Mock).mockResolvedValueOnce(0);\n\n        await service.onPhaseBlocked(task, 'plan', 'lint failed', 'blocked');\n\n        expect(eventService.record).toHaveBeenCalledWith(\n          10,\n          'phase_blocked_comment_posted:plan',\n          'phase-hooks',\n          { phase: 'plan', category: 'blocked' },\n        );\n        expect(github.addLabel).toHaveBeenCalledWith(\n          'org',\n          'repo',\n          42,\n          'agent-blocked',\n        );\n        expect(github.postComment).toHaveBeenCalledWith(\n          'org',\n          'repo',\n          42,\n          expect.stringContaining('lint failed'),\n        );\n      });\n\n      it('skips duplicate blocked comment when dedup event already recorded', async () => {\n        const task = { ...baseTask, taskId: 10 };\n        (eventService.countByType as jest.Mock).mockResolvedValueOnce(1); // already posted\n\n        await service.onPhaseBlocked(task, 'plan', 'lint failed', 'blocked');\n\n        expect(github.addLabel).not.toHaveBeenCalled();\n        expect(github.postComment).not.toHaveBeenCalled();\n      });\n\n      it('checks dedup event key scoped to the phase', async () => {\n        const task = { ...baseTask, taskId: 10 };\n        (eventService.countByType as jest.Mock).mockResolvedValueOnce(0);\n\n        await service.onPhaseBlocked(task, 'deliver', 'push failed', 'blocked');\n\n        expect(eventService.countByType).toHaveBeenCalledWith(\n          10,\n          'phase_blocked_comment_posted:deliver',\n        );\n      });\n\n      it('proceeds without dedup guard when taskId is undefined', async () => {\n        // Tasks without a taskId get no event-based dedup — falls back to always posting\n        await service.onPhaseBlocked(baseTask, 'plan', 'reason', 'blocked');\n\n        expect(eventService.countByType).not.toHaveBeenCalled();\n        expect(github.postComment).toHaveBeenCalled();\n      });\n\n      it('proceeds to post if countByType rejects (fail-open)', async () => {\n        const task = { ...baseTask, taskId: 10 };\n        (eventService.countByType as jest.Mock).mockRejectedValueOnce(\n          new Error('DB error'),\n        );\n\n        await service.onPhaseBlocked(task, 'plan', 'reason', 'blocked');\n\n        // countByType rejected → treated as 0 → proceeds normally\n        expect(github.postComment).toHaveBeenCalled();\n      });\n    });\n\n    describe('onCompoundComplete() dedup', () => {\n      it('posts completion comment on first call and records dedup event', async () => {\n        const task = { ...baseTask, taskId: 20 };\n        (eventService.countByType as jest.Mock).mockResolvedValueOnce(0);\n\n        await service.onPhaseComplete(task, 'compound');\n\n        expect(eventService.record).toHaveBeenCalledWith(\n          20,\n          'compound_complete_comment_posted',\n          'phase-hooks',\n          {},\n        );\n        expect(github.postComment).toHaveBeenCalledWith(\n          'org',\n          'repo',\n          42,\n          '**Task complete.** All phases finished successfully.',\n        );\n      });\n\n      it('skips duplicate completion comment when dedup event already recorded', async () => {\n        const task = { ...baseTask, taskId: 20 };\n        (eventService.countByType as jest.Mock).mockResolvedValueOnce(1); // already posted\n\n        await service.onPhaseComplete(task, 'compound');\n\n        expect(compound.writeLearnings).not.toHaveBeenCalled();\n        expect(github.postComment).not.toHaveBeenCalled();\n      });\n\n      it('still posts when taskId is undefined (no dedup guard)', async () => {\n        await service.onPhaseComplete(baseTask, 'compound');\n\n        expect(eventService.countByType).not.toHaveBeenCalledWith(\n          expect.anything(),\n          'compound_complete_comment_posted',\n        );\n        expect(github.postComment).toHaveBeenCalledWith(\n          'org',\n          'repo',\n          42,\n          '**Task complete.** All phases finished successfully.',\n        );\n      });\n    });\n  });\n});\n","structuredPatch":[{"oldStart":585,"oldLines":7,"newStart":585,"newLines":7,"lines":["       github.addLabel.mockRejectedValueOnce(new Error('API error'));"," ","       await expect(","-        service.onPhaseBlocked(baseTask, 'execute', 'blocked', 'blocked'),","+        service.onPhaseBlocked(baseTask, 'deliver', 'blocked', 'blocked'),","       ).resolves.toBeUndefined();"," ","       expect(github.postComment).toHaveBeenCalled();"]},{"oldStart":595,"oldLines":7,"newStart":595,"newLines":7,"lines":["       github.postComment.mockRejectedValueOnce(new Error('API error'));"," ","       await expect(","-        service.onPhaseBlocked(baseTask, 'execute', 'blocked', 'blocked'),","+        service.onPhaseBlocked(baseTask, 'deliver', 'blocked', 'blocked'),","       ).resolves.toBeUndefined();","     });","   });"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01R3ZwTXehESvqM6TcksLixn","type":"message","role":"assistant","content":[{"type":"text","text":"Now update compound.service.spec.ts:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":567,"cache_read_input_tokens":105249,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":567},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"225225b3-fa88-4762-b36a-f09a05b3fd61"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01R3ZwTXehESvqM6TcksLixn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XnkPsLYHEzRoKmyR8LEPyd","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/hooks/compound.service.spec.ts","old_string":"      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'plan_completed', taskId: 1 } as EventEntity,\n        { type: 'execute_completed', taskId: 1 } as EventEntity,\n      ]);","new_string":"      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'plan_completed', taskId: 1 } as EventEntity,\n        { type: 'deliver_completed', taskId: 1 } as EventEntity,\n      ]);"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":567,"cache_read_input_tokens":105249,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":567},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"326be844-fbfb-4459-852e-07feeaa7fee7"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XnkPsLYHEzRoKmyR8LEPyd","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/hooks/compound.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"81512a45-901f-4557-9d5f-53b15029875d","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/hooks/compound.service.spec.ts","oldString":"      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'plan_completed', taskId: 1 } as EventEntity,\n        { type: 'execute_completed', taskId: 1 } as EventEntity,\n      ]);","newString":"      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'plan_completed', taskId: 1 } as EventEntity,\n        { type: 'deliver_completed', taskId: 1 } as EventEntity,\n      ]);","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { CompoundService } from './compound.service';\nimport { GitHubService } from '../github';\nimport { EventService } from '../event/event.service';\nimport type { PhaseTask } from './phase-hooks.service';\nimport type { EventEntity } from '../database';\n\ndescribe('CompoundService', () => {\n  let service: CompoundService;\n  let github: jest.Mocked<GitHubService>;\n  let eventService: jest.Mocked<EventService>;\n\n  const baseTask: PhaseTask = {\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    taskId: 1,\n  };\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        CompoundService,\n        {\n          provide: GitHubService,\n          useValue: {\n            getFileContent: jest.fn().mockResolvedValue(null),\n            createOrUpdateFile: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            findByTask: jest.fn().mockResolvedValue([]),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get<CompoundService>(CompoundService);\n    github = module.get(GitHubService);\n    eventService = module.get(EventService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  describe('writeLearnings', () => {\n    it('skips when task has no taskId', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: undefined };\n\n      await service.writeLearnings('org', 'repo', task);\n\n      expect(github.createOrUpdateFile).not.toHaveBeenCalled();\n    });\n\n    it('skips when no phase events exist', async () => {\n      eventService.findByTask.mockResolvedValueOnce([]);\n\n      await service.writeLearnings('org', 'repo', baseTask);\n\n      expect(github.createOrUpdateFile).not.toHaveBeenCalled();\n    });\n\n    it('creates AGENTS.md when file does not exist', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'plan_completed', taskId: 1 } as EventEntity,\n        { type: 'execute_completed', taskId: 1 } as EventEntity,\n      ]);\n      github.getFileContent.mockResolvedValueOnce(null);\n\n      await service.writeLearnings('org', 'repo', baseTask);\n\n      expect(github.createOrUpdateFile).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        'AGENTS.md',\n        expect.stringContaining('# Agent Learnings'),\n        expect.stringContaining('issue #42'),\n        undefined,\n      );\n    });\n\n    it('appends to existing AGENTS.md with sha', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'execute_completed', taskId: 1 } as EventEntity,\n      ]);\n      github.getFileContent.mockResolvedValueOnce({\n        content: '# Agent Learnings\\n\\n## Previous entry\\n',\n        sha: 'abc123',\n      });\n\n      await service.writeLearnings('org', 'repo', baseTask);\n\n      expect(github.createOrUpdateFile).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        'AGENTS.md',\n        expect.stringContaining('## Previous entry'),\n        expect.stringContaining('issue #42'),\n        'abc123',\n      );\n\n      // Verify it appends, not overwrites\n      const content = github.createOrUpdateFile.mock.calls[0][3];\n      expect(content).toMatch(/^# Agent Learnings/);\n      expect(content).toContain('## Previous entry');\n      expect(content).toContain('## Issue #42');\n    });\n\n    it('includes PR number in entry when present', async () => {\n      const task: PhaseTask = { ...baseTask, prNumber: 99 };\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'deliver_completed', taskId: 1 } as EventEntity,\n      ]);\n\n      await service.writeLearnings('org', 'repo', task);\n\n      const content = github.createOrUpdateFile.mock.calls[0][3];\n      expect(content).toContain('**PR:** #99');\n    });\n\n    it('deduplicates phases keeping last occurrence', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'execute_failed', taskId: 1 } as EventEntity,\n        { type: 'execute_completed', taskId: 1 } as EventEntity,\n      ]);\n\n      const summary = await service.buildSummary(baseTask);\n\n      expect(summary).toBe('execute: completed');\n    });\n\n    it('includes skipped phases in summary', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'review_skipped', taskId: 1 } as EventEntity,\n      ]);\n\n      const summary = await service.buildSummary(baseTask);\n\n      expect(summary).toBe('refine: completed, review: skipped');\n    });\n  });\n\n  describe('buildSummary', () => {\n    it('returns null when no taskId', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: undefined };\n      const summary = await service.buildSummary(task);\n      expect(summary).toBeNull();\n    });\n\n    it('returns null when no phase events', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'claude_invocation_complete', taskId: 1 } as EventEntity,\n      ]);\n      const summary = await service.buildSummary(baseTask);\n      expect(summary).toBeNull();\n    });\n\n    it('returns summary string for completed phases', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'plan_completed', taskId: 1 } as EventEntity,\n      ]);\n      const summary = await service.buildSummary(baseTask);\n      expect(summary).toBe('refine: completed, plan: completed');\n    });\n\n    it('excludes compound_completed from summary', async () => {\n      // compound_completed is recorded before the hook runs; must not appear\n      // in the phase-trace written to AGENTS.md.\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'deliver_completed', taskId: 1 } as EventEntity,\n        { type: 'compound_completed', taskId: 1 } as EventEntity,\n      ]);\n      const summary = await service.buildSummary(baseTask);\n      expect(summary).toBe('refine: completed, deliver: completed');\n      expect(summary).not.toContain('compound');\n    });\n\n    it('returns null when only compound events exist', async () => {\n      // No earlier-phase events means nothing useful to summarise.\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'compound_completed', taskId: 1 } as EventEntity,\n      ]);\n      const summary = await service.buildSummary(baseTask);\n      expect(summary).toBeNull();\n    });\n  });\n});\n","structuredPatch":[{"oldStart":68,"oldLines":7,"newStart":68,"newLines":7,"lines":["       eventService.findByTask.mockResolvedValueOnce([","         { type: 'refine_completed', taskId: 1 } as EventEntity,","         { type: 'plan_completed', taskId: 1 } as EventEntity,","-        { type: 'execute_completed', taskId: 1 } as EventEntity,","+        { type: 'deliver_completed', taskId: 1 } as EventEntity,","       ]);","       github.getFileContent.mockResolvedValueOnce(null);"," "]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01LgEZgtVjzg7Y3MLGL2GjZn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TzRzoCHdMNAUHUuCZ4eJw4","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/hooks/compound.service.spec.ts","old_string":"      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'execute_completed', taskId: 1 } as EventEntity,\n      ]);\n      github.getFileContent.mockResolvedValueOnce({\n        content: '# Agent Learnings\\n\\n## Previous entry\\n',","new_string":"      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'deliver_completed', taskId: 1 } as EventEntity,\n      ]);\n      github.getFileContent.mockResolvedValueOnce({\n        content: '# Agent Learnings\\n\\n## Previous entry\\n',"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":386,"cache_read_input_tokens":105816,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":386},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"2e19a1a2-dae2-4964-bedb-e366df3eda8e"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TzRzoCHdMNAUHUuCZ4eJw4","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/hooks/compound.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"d162dcbd-83c4-4283-bcf3-9b66c3a40e91","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/hooks/compound.service.spec.ts","oldString":"      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'execute_completed', taskId: 1 } as EventEntity,\n      ]);\n      github.getFileContent.mockResolvedValueOnce({\n        content: '# Agent Learnings\\n\\n## Previous entry\\n',","newString":"      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'deliver_completed', taskId: 1 } as EventEntity,\n      ]);\n      github.getFileContent.mockResolvedValueOnce({\n        content: '# Agent Learnings\\n\\n## Previous entry\\n',","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { CompoundService } from './compound.service';\nimport { GitHubService } from '../github';\nimport { EventService } from '../event/event.service';\nimport type { PhaseTask } from './phase-hooks.service';\nimport type { EventEntity } from '../database';\n\ndescribe('CompoundService', () => {\n  let service: CompoundService;\n  let github: jest.Mocked<GitHubService>;\n  let eventService: jest.Mocked<EventService>;\n\n  const baseTask: PhaseTask = {\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    taskId: 1,\n  };\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        CompoundService,\n        {\n          provide: GitHubService,\n          useValue: {\n            getFileContent: jest.fn().mockResolvedValue(null),\n            createOrUpdateFile: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            findByTask: jest.fn().mockResolvedValue([]),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get<CompoundService>(CompoundService);\n    github = module.get(GitHubService);\n    eventService = module.get(EventService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  describe('writeLearnings', () => {\n    it('skips when task has no taskId', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: undefined };\n\n      await service.writeLearnings('org', 'repo', task);\n\n      expect(github.createOrUpdateFile).not.toHaveBeenCalled();\n    });\n\n    it('skips when no phase events exist', async () => {\n      eventService.findByTask.mockResolvedValueOnce([]);\n\n      await service.writeLearnings('org', 'repo', baseTask);\n\n      expect(github.createOrUpdateFile).not.toHaveBeenCalled();\n    });\n\n    it('creates AGENTS.md when file does not exist', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'plan_completed', taskId: 1 } as EventEntity,\n        { type: 'deliver_completed', taskId: 1 } as EventEntity,\n      ]);\n      github.getFileContent.mockResolvedValueOnce(null);\n\n      await service.writeLearnings('org', 'repo', baseTask);\n\n      expect(github.createOrUpdateFile).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        'AGENTS.md',\n        expect.stringContaining('# Agent Learnings'),\n        expect.stringContaining('issue #42'),\n        undefined,\n      );\n    });\n\n    it('appends to existing AGENTS.md with sha', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'execute_completed', taskId: 1 } as EventEntity,\n      ]);\n      github.getFileContent.mockResolvedValueOnce({\n        content: '# Agent Learnings\\n\\n## Previous entry\\n',\n        sha: 'abc123',\n      });\n\n      await service.writeLearnings('org', 'repo', baseTask);\n\n      expect(github.createOrUpdateFile).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        'AGENTS.md',\n        expect.stringContaining('## Previous entry'),\n        expect.stringContaining('issue #42'),\n        'abc123',\n      );\n\n      // Verify it appends, not overwrites\n      const content = github.createOrUpdateFile.mock.calls[0][3];\n      expect(content).toMatch(/^# Agent Learnings/);\n      expect(content).toContain('## Previous entry');\n      expect(content).toContain('## Issue #42');\n    });\n\n    it('includes PR number in entry when present', async () => {\n      const task: PhaseTask = { ...baseTask, prNumber: 99 };\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'deliver_completed', taskId: 1 } as EventEntity,\n      ]);\n\n      await service.writeLearnings('org', 'repo', task);\n\n      const content = github.createOrUpdateFile.mock.calls[0][3];\n      expect(content).toContain('**PR:** #99');\n    });\n\n    it('deduplicates phases keeping last occurrence', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'execute_failed', taskId: 1 } as EventEntity,\n        { type: 'execute_completed', taskId: 1 } as EventEntity,\n      ]);\n\n      const summary = await service.buildSummary(baseTask);\n\n      expect(summary).toBe('execute: completed');\n    });\n\n    it('includes skipped phases in summary', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'review_skipped', taskId: 1 } as EventEntity,\n      ]);\n\n      const summary = await service.buildSummary(baseTask);\n\n      expect(summary).toBe('refine: completed, review: skipped');\n    });\n  });\n\n  describe('buildSummary', () => {\n    it('returns null when no taskId', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: undefined };\n      const summary = await service.buildSummary(task);\n      expect(summary).toBeNull();\n    });\n\n    it('returns null when no phase events', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'claude_invocation_complete', taskId: 1 } as EventEntity,\n      ]);\n      const summary = await service.buildSummary(baseTask);\n      expect(summary).toBeNull();\n    });\n\n    it('returns summary string for completed phases', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'plan_completed', taskId: 1 } as EventEntity,\n      ]);\n      const summary = await service.buildSummary(baseTask);\n      expect(summary).toBe('refine: completed, plan: completed');\n    });\n\n    it('excludes compound_completed from summary', async () => {\n      // compound_completed is recorded before the hook runs; must not appear\n      // in the phase-trace written to AGENTS.md.\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'deliver_completed', taskId: 1 } as EventEntity,\n        { type: 'compound_completed', taskId: 1 } as EventEntity,\n      ]);\n      const summary = await service.buildSummary(baseTask);\n      expect(summary).toBe('refine: completed, deliver: completed');\n      expect(summary).not.toContain('compound');\n    });\n\n    it('returns null when only compound events exist', async () => {\n      // No earlier-phase events means nothing useful to summarise.\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'compound_completed', taskId: 1 } as EventEntity,\n      ]);\n      const summary = await service.buildSummary(baseTask);\n      expect(summary).toBeNull();\n    });\n  });\n});\n","structuredPatch":[{"oldStart":86,"oldLines":7,"newStart":86,"newLines":7,"lines":[" ","     it('appends to existing AGENTS.md with sha', async () => {","       eventService.findByTask.mockResolvedValueOnce([","-        { type: 'execute_completed', taskId: 1 } as EventEntity,","+        { type: 'deliver_completed', taskId: 1 } as EventEntity,","       ]);","       github.getFileContent.mockResolvedValueOnce({","         content: '# Agent Learnings\\n\\n## Previous entry\\n',"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01NvnmsUeCoMyufDDRwMkkCy","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016Bv6ASdstsGtJwvXJCiyWr","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/hooks/compound.service.spec.ts","old_string":"      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'execute_failed', taskId: 1 } as EventEntity,\n        { type: 'execute_completed', taskId: 1 } as EventEntity,\n      ]);\n\n      const summary = await service.buildSummary(baseTask);\n\n      expect(summary).toBe('execute: completed');","new_string":"      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'deliver_failed', taskId: 1 } as EventEntity,\n        { type: 'deliver_completed', taskId: 1 } as EventEntity,\n      ]);\n\n      const summary = await service.buildSummary(baseTask);\n\n      expect(summary).toBe('deliver: completed');"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":463,"cache_read_input_tokens":106202,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":463},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"0876b197-e825-42d3-9c97-21a287772e0d"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016Bv6ASdstsGtJwvXJCiyWr","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/hooks/compound.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"bbb01520-5a2f-49d9-bf4a-8ad6082d0beb","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/hooks/compound.service.spec.ts","oldString":"      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'execute_failed', taskId: 1 } as EventEntity,\n        { type: 'execute_completed', taskId: 1 } as EventEntity,\n      ]);\n\n      const summary = await service.buildSummary(baseTask);\n\n      expect(summary).toBe('execute: completed');","newString":"      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'deliver_failed', taskId: 1 } as EventEntity,\n        { type: 'deliver_completed', taskId: 1 } as EventEntity,\n      ]);\n\n      const summary = await service.buildSummary(baseTask);\n\n      expect(summary).toBe('deliver: completed');","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { CompoundService } from './compound.service';\nimport { GitHubService } from '../github';\nimport { EventService } from '../event/event.service';\nimport type { PhaseTask } from './phase-hooks.service';\nimport type { EventEntity } from '../database';\n\ndescribe('CompoundService', () => {\n  let service: CompoundService;\n  let github: jest.Mocked<GitHubService>;\n  let eventService: jest.Mocked<EventService>;\n\n  const baseTask: PhaseTask = {\n    issue: 42,\n    repo: 'org/repo',\n    directive: 'auto',\n    taskId: 1,\n  };\n\n  beforeEach(async () => {\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        CompoundService,\n        {\n          provide: GitHubService,\n          useValue: {\n            getFileContent: jest.fn().mockResolvedValue(null),\n            createOrUpdateFile: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            findByTask: jest.fn().mockResolvedValue([]),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get<CompoundService>(CompoundService);\n    github = module.get(GitHubService);\n    eventService = module.get(EventService);\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  describe('writeLearnings', () => {\n    it('skips when task has no taskId', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: undefined };\n\n      await service.writeLearnings('org', 'repo', task);\n\n      expect(github.createOrUpdateFile).not.toHaveBeenCalled();\n    });\n\n    it('skips when no phase events exist', async () => {\n      eventService.findByTask.mockResolvedValueOnce([]);\n\n      await service.writeLearnings('org', 'repo', baseTask);\n\n      expect(github.createOrUpdateFile).not.toHaveBeenCalled();\n    });\n\n    it('creates AGENTS.md when file does not exist', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'plan_completed', taskId: 1 } as EventEntity,\n        { type: 'deliver_completed', taskId: 1 } as EventEntity,\n      ]);\n      github.getFileContent.mockResolvedValueOnce(null);\n\n      await service.writeLearnings('org', 'repo', baseTask);\n\n      expect(github.createOrUpdateFile).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        'AGENTS.md',\n        expect.stringContaining('# Agent Learnings'),\n        expect.stringContaining('issue #42'),\n        undefined,\n      );\n    });\n\n    it('appends to existing AGENTS.md with sha', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'deliver_completed', taskId: 1 } as EventEntity,\n      ]);\n      github.getFileContent.mockResolvedValueOnce({\n        content: '# Agent Learnings\\n\\n## Previous entry\\n',\n        sha: 'abc123',\n      });\n\n      await service.writeLearnings('org', 'repo', baseTask);\n\n      expect(github.createOrUpdateFile).toHaveBeenCalledWith(\n        'org',\n        'repo',\n        'AGENTS.md',\n        expect.stringContaining('## Previous entry'),\n        expect.stringContaining('issue #42'),\n        'abc123',\n      );\n\n      // Verify it appends, not overwrites\n      const content = github.createOrUpdateFile.mock.calls[0][3];\n      expect(content).toMatch(/^# Agent Learnings/);\n      expect(content).toContain('## Previous entry');\n      expect(content).toContain('## Issue #42');\n    });\n\n    it('includes PR number in entry when present', async () => {\n      const task: PhaseTask = { ...baseTask, prNumber: 99 };\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'deliver_completed', taskId: 1 } as EventEntity,\n      ]);\n\n      await service.writeLearnings('org', 'repo', task);\n\n      const content = github.createOrUpdateFile.mock.calls[0][3];\n      expect(content).toContain('**PR:** #99');\n    });\n\n    it('deduplicates phases keeping last occurrence', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'execute_failed', taskId: 1 } as EventEntity,\n        { type: 'execute_completed', taskId: 1 } as EventEntity,\n      ]);\n\n      const summary = await service.buildSummary(baseTask);\n\n      expect(summary).toBe('execute: completed');\n    });\n\n    it('includes skipped phases in summary', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'review_skipped', taskId: 1 } as EventEntity,\n      ]);\n\n      const summary = await service.buildSummary(baseTask);\n\n      expect(summary).toBe('refine: completed, review: skipped');\n    });\n  });\n\n  describe('buildSummary', () => {\n    it('returns null when no taskId', async () => {\n      const task: PhaseTask = { ...baseTask, taskId: undefined };\n      const summary = await service.buildSummary(task);\n      expect(summary).toBeNull();\n    });\n\n    it('returns null when no phase events', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'claude_invocation_complete', taskId: 1 } as EventEntity,\n      ]);\n      const summary = await service.buildSummary(baseTask);\n      expect(summary).toBeNull();\n    });\n\n    it('returns summary string for completed phases', async () => {\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'plan_completed', taskId: 1 } as EventEntity,\n      ]);\n      const summary = await service.buildSummary(baseTask);\n      expect(summary).toBe('refine: completed, plan: completed');\n    });\n\n    it('excludes compound_completed from summary', async () => {\n      // compound_completed is recorded before the hook runs; must not appear\n      // in the phase-trace written to AGENTS.md.\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'refine_completed', taskId: 1 } as EventEntity,\n        { type: 'deliver_completed', taskId: 1 } as EventEntity,\n        { type: 'compound_completed', taskId: 1 } as EventEntity,\n      ]);\n      const summary = await service.buildSummary(baseTask);\n      expect(summary).toBe('refine: completed, deliver: completed');\n      expect(summary).not.toContain('compound');\n    });\n\n    it('returns null when only compound events exist', async () => {\n      // No earlier-phase events means nothing useful to summarise.\n      eventService.findByTask.mockResolvedValueOnce([\n        { type: 'compound_completed', taskId: 1 } as EventEntity,\n      ]);\n      const summary = await service.buildSummary(baseTask);\n      expect(summary).toBeNull();\n    });\n  });\n});\n","structuredPatch":[{"oldStart":125,"oldLines":13,"newStart":125,"newLines":13,"lines":[" ","     it('deduplicates phases keeping last occurrence', async () => {","       eventService.findByTask.mockResolvedValueOnce([","-        { type: 'execute_failed', taskId: 1 } as EventEntity,","-        { type: 'execute_completed', taskId: 1 } as EventEntity,","+        { type: 'deliver_failed', taskId: 1 } as EventEntity,","+        { type: 'deliver_completed', taskId: 1 } as EventEntity,","       ]);"," ","       const summary = await service.buildSummary(baseTask);"," ","-      expect(summary).toBe('execute: completed');","+      expect(summary).toBe('deliver: completed');","     });"," ","     it('includes skipped phases in summary', async () => {"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01HsMt7KrZaBg9XS9A1LVXK1","type":"message","role":"assistant","content":[{"type":"text","text":"Now update watchdog.service.spec.ts - replace all `currentPhase: 'execute'` with `'deliver'`:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":389,"cache_read_input_tokens":106665,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":389},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"a109682a-19d3-477a-a397-eb63a32cb2e9"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01HsMt7KrZaBg9XS9A1LVXK1","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019kSoo1BvdcopNbyhnmrpGj","name":"Edit","input":{"replace_all":true,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/watchdog/watchdog.service.spec.ts","old_string":"      currentPhase: 'execute',","new_string":"      currentPhase: 'deliver',"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":389,"cache_read_input_tokens":106665,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":389},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"595b419d-2648-43f6-b220-986f89d8fc56"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019kSoo1BvdcopNbyhnmrpGj","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/watchdog/watchdog.service.spec.ts has been updated. All occurrences of '      currentPhase: 'execute',' were successfully replaced with '      currentPhase: 'deliver','."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"d7af4595-4be7-47ed-92a3-b9c69d78b2f2","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/watchdog/watchdog.service.spec.ts","oldString":"      currentPhase: 'execute',","newString":"      currentPhase: 'deliver',","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { WatchdogService } from './watchdog.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { EventService } from '../event/event.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventBusService } from '../event-bus';\nimport { TaskEntity } from '../database/entities';\nimport { EventEntity } from '../database/entities';\n\ndescribe('WatchdogService', () => {\n  let service: WatchdogService;\n  let taskStateService: jest.Mocked<TaskStateService>;\n  let eventService: jest.Mocked<EventService>;\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let phaseHooks: jest.Mocked<PhaseHooksService>;\n  let originalStuck: string | undefined;\n  let originalSlow: string | undefined;\n\n  beforeAll(() => {\n    // Pin thresholds so tests are not affected by environment configuration.\n    originalStuck = process.env.STUCK_THRESHOLD_MS;\n    originalSlow = process.env.SLOW_THRESHOLD_MS;\n    process.env.STUCK_THRESHOLD_MS = '1800000'; // 30 min\n    process.env.SLOW_THRESHOLD_MS = '900000'; // 15 min\n  });\n\n  afterAll(() => {\n    if (originalStuck !== undefined) {\n      process.env.STUCK_THRESHOLD_MS = originalStuck;\n    } else {\n      delete process.env.STUCK_THRESHOLD_MS;\n    }\n    if (originalSlow !== undefined) {\n      process.env.SLOW_THRESHOLD_MS = originalSlow;\n    } else {\n      delete process.env.SLOW_THRESHOLD_MS;\n    }\n  });\n\n  // Pin thresholds so tests are independent of the runtime environment.\n  // WatchdogService reads these from process.env at instantiation time.\n  const ORIG_STUCK = process.env.STUCK_THRESHOLD_MS;\n  const ORIG_SLOW = process.env.SLOW_THRESHOLD_MS;\n\n  beforeEach(async () => {\n    process.env.STUCK_THRESHOLD_MS = '1800000'; // 30 min\n    process.env.SLOW_THRESHOLD_MS = '900000'; // 15 min\n\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        WatchdogService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByStatus: jest.fn().mockResolvedValue([]),\n            findByStatuses: jest.fn().mockResolvedValue([]),\n            save: jest\n              .fn()\n              .mockImplementation((t: TaskEntity) => Promise.resolve(t)),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn().mockResolvedValue({}),\n            findByTask: jest.fn().mockResolvedValue([]),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseBlocked: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get<WatchdogService>(WatchdogService);\n    taskStateService = module.get(TaskStateService);\n    eventService = module.get(EventService);\n    eventBus = module.get(EventBusService);\n    phaseHooks = module.get(PhaseHooksService);\n  });\n\n  afterEach(() => {\n    if (ORIG_STUCK === undefined) {\n      delete process.env.STUCK_THRESHOLD_MS;\n    } else {\n      process.env.STUCK_THRESHOLD_MS = ORIG_STUCK;\n    }\n    if (ORIG_SLOW === undefined) {\n      delete process.env.SLOW_THRESHOLD_MS;\n    } else {\n      process.env.SLOW_THRESHOLD_MS = ORIG_SLOW;\n    }\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('does nothing when no active tasks', async () => {\n    taskStateService.findByStatuses.mockResolvedValue([]);\n    await service.checkForStuckTasks();\n    expect(taskStateService.save).not.toHaveBeenCalled();\n  });\n\n  it('marks task as stuck when older than stuck threshold', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'execute',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(task.status).toBe('stuck');\n    expect(taskStateService.save).toHaveBeenCalledWith(task);\n    expect(eventService.record).toHaveBeenCalledWith(\n      1,\n      'watchdog_detected_stuck',\n      'watchdog',\n      expect.objectContaining({ phase: 'execute' }),\n    );\n  });\n\n  it('includes lastEventAt and lastEventType in stuck event metadata', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'execute',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    const lastEvent = {\n      id: 10,\n      taskId: 1,\n      type: 'phase_started',\n      source: 'dispatch',\n      createdAt: new Date('2026-03-23T10:00:00.000Z'),\n    } as EventEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.findByTask as jest.Mock).mockResolvedValue([lastEvent]);\n\n    await service.checkForStuckTasks();\n\n    expect(eventService.record).toHaveBeenCalledWith(\n      1,\n      'watchdog_detected_stuck',\n      'watchdog',\n      expect.objectContaining({\n        lastEventType: 'phase_started',\n        lastEventAt: '2026-03-23T10:00:00.000Z',\n      }),\n    );\n  });\n\n  it('emits watchdog_detected_slow for tasks between slow and stuck thresholds', async () => {\n    // 20 min old — past default slow threshold (15 min) but under stuck threshold (30 min)\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 20 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.countByType as jest.Mock).mockResolvedValue(0);\n\n    await service.checkForStuckTasks();\n\n    expect(task.status).toBe('active');\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(eventService.record).toHaveBeenCalledWith(\n      2,\n      'watchdog_detected_slow',\n      'watchdog',\n      expect.objectContaining({ phase: 'plan' }),\n    );\n  });\n\n  it('does not re-emit watchdog_detected_slow if already emitted', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 20 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.countByType as jest.Mock).mockResolvedValue(1);\n\n    await service.checkForStuckTasks();\n\n    expect(eventService.record).not.toHaveBeenCalled();\n  });\n\n  it('does NOT mark task as stuck when younger than threshold', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(eventService.record).not.toHaveBeenCalled();\n  });\n\n  it('does not mark stuck when last event is within the stuck threshold', async () => {\n    // updatedAt is old but a recent event was recorded (e.g. an intermediate log).\n    // The watchdog should use the most-recent of updatedAt / lastEvent.createdAt.\n    const task = {\n      id: 3,\n      issue: 20,\n      repo: 'AgentSDE/test',\n      currentPhase: 'execute',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000), // 35 min ago\n    } as TaskEntity;\n\n    const recentEvent = {\n      id: 99,\n      taskId: 3,\n      type: 'phase_started',\n      source: 'phase-router',\n      createdAt: new Date(Date.now() - 5 * 60 * 1000), // 5 min ago\n    } as EventEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.findByTask as jest.Mock).mockResolvedValue([recentEvent]);\n\n    await service.checkForStuckTasks();\n\n    // Task should remain active — last event is recent\n    expect(task.status).toBe('active');\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(eventService.record).not.toHaveBeenCalled();\n  });\n\n  it('marks only stuck tasks when mixed ages', async () => {\n    const stuckTask = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'execute',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 45 * 60 * 1000),\n    } as TaskEntity;\n\n    const freshTask = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([stuckTask, freshTask]);\n    await service.checkForStuckTasks();\n\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(taskStateService.save).toHaveBeenCalledWith(stuckTask);\n  });\n\n  it('emits task.stuck when a stuck task is detected', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'execute',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(eventBus.emit).toHaveBeenCalledWith(\n      'task.stuck',\n      expect.objectContaining({\n        taskId: 1,\n        issue: 42,\n        repo: 'AgentSDE/test',\n        phase: 'execute',\n      }),\n    );\n  });\n\n  it('does not emit task.stuck for non-stuck tasks', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(eventBus.emit).not.toHaveBeenCalledWith(\n      'task.stuck',\n      expect.anything(),\n    );\n  });\n\n  it('calls onPhaseBlocked when a stuck task is detected', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'execute',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(phaseHooks.onPhaseBlocked).toHaveBeenCalledWith(\n      expect.objectContaining({ issue: 42, repo: 'AgentSDE/test' }),\n      'execute',\n      expect.stringContaining('stuck'),\n      'stuck',\n    );\n  });\n\n  it('does not call onPhaseBlocked for non-stuck tasks', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(phaseHooks.onPhaseBlocked).not.toHaveBeenCalled();\n  });\n});\n","structuredPatch":[{"oldStart":119,"oldLines":7,"newStart":119,"newLines":7,"lines":["       id: 1,","       issue: 42,","       repo: 'AgentSDE/test',","-      currentPhase: 'execute',","+      currentPhase: 'deliver',","       status: 'active',","       updatedAt: new Date(Date.now() - 35 * 60 * 1000),","     } as TaskEntity;"]},{"oldStart":142,"oldLines":7,"newStart":142,"newLines":7,"lines":["       id: 1,","       issue: 42,","       repo: 'AgentSDE/test',","-      currentPhase: 'execute',","+      currentPhase: 'deliver',","       status: 'active',","       updatedAt: new Date(Date.now() - 35 * 60 * 1000),","     } as TaskEntity;"]},{"oldStart":239,"oldLines":7,"newStart":239,"newLines":7,"lines":["       id: 3,","       issue: 20,","       repo: 'AgentSDE/test',","-      currentPhase: 'execute',","+      currentPhase: 'deliver',","       status: 'active',","       updatedAt: new Date(Date.now() - 35 * 60 * 1000), // 35 min ago","     } as TaskEntity;"]},{"oldStart":268,"oldLines":7,"newStart":268,"newLines":7,"lines":["       id: 1,","       issue: 42,","       repo: 'AgentSDE/test',","-      currentPhase: 'execute',","+      currentPhase: 'deliver',","       status: 'active',","       updatedAt: new Date(Date.now() - 45 * 60 * 1000),","     } as TaskEntity;"]},{"oldStart":294,"oldLines":7,"newStart":294,"newLines":7,"lines":["       id: 1,","       issue: 42,","       repo: 'AgentSDE/test',","-      currentPhase: 'execute',","+      currentPhase: 'deliver',","       status: 'active',","       updatedAt: new Date(Date.now() - 35 * 60 * 1000),","     } as TaskEntity;"]},{"oldStart":337,"oldLines":7,"newStart":337,"newLines":7,"lines":["       id: 1,","       issue: 42,","       repo: 'AgentSDE/test',","-      currentPhase: 'execute',","+      currentPhase: 'deliver',","       status: 'active',","       updatedAt: new Date(Date.now() - 35 * 60 * 1000),","     } as TaskEntity;"]}],"userModified":false,"replaceAll":true}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_015XLyiToi4sHqPTp9PcdAGh","type":"message","role":"assistant","content":[{"type":"text","text":"Now check the watchdog spec for `phase: 'execute'` in event records:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":285,"cache_read_input_tokens":107054,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":285},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"bbfa2717-b6c5-4fd9-9e5b-0842b859f68e"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_015XLyiToi4sHqPTp9PcdAGh","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015rNRTFpmH8eLM28c6M8jiM","name":"Grep","input":{"pattern":"execute","path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/watchdog/watchdog.service.spec.ts","output_mode":"content","-n":true},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":285,"cache_read_input_tokens":107054,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":285},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"41c8b3e8-525d-49f9-ad7a-73bb1357ae01"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015rNRTFpmH8eLM28c6M8jiM","type":"tool_result","content":"136:      expect.objectContaining({ phase: 'execute' }),\n311:        phase: 'execute',\n350:      'execute',"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"4b724acd-6980-43bf-a240-13d74f6b22fb","tool_use_result":{"mode":"content","numFiles":0,"filenames":[],"content":"136:      expect.objectContaining({ phase: 'execute' }),\n311:        phase: 'execute',\n350:      'execute',","numLines":3}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01Ua3VUvbz5D7PG8BdBuYgcJ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DRFNsW1PpEpQQ6uDWcQFcd","name":"Edit","input":{"replace_all":true,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/watchdog/watchdog.service.spec.ts","old_string":"{ phase: 'execute' }","new_string":"{ phase: 'deliver' }"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":496,"cache_read_input_tokens":107054,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":496},"output_tokens":65,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"fe2f5d29-fd54-4a28-99a2-0901d1881473"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DRFNsW1PpEpQQ6uDWcQFcd","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/watchdog/watchdog.service.spec.ts has been updated. All occurrences of '{ phase: 'execute' }' were successfully replaced with '{ phase: 'deliver' }'."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"78bfb630-e18c-4d9c-988b-f8acf7d0c590","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/watchdog/watchdog.service.spec.ts","oldString":"{ phase: 'execute' }","newString":"{ phase: 'deliver' }","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { WatchdogService } from './watchdog.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { EventService } from '../event/event.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventBusService } from '../event-bus';\nimport { TaskEntity } from '../database/entities';\nimport { EventEntity } from '../database/entities';\n\ndescribe('WatchdogService', () => {\n  let service: WatchdogService;\n  let taskStateService: jest.Mocked<TaskStateService>;\n  let eventService: jest.Mocked<EventService>;\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let phaseHooks: jest.Mocked<PhaseHooksService>;\n  let originalStuck: string | undefined;\n  let originalSlow: string | undefined;\n\n  beforeAll(() => {\n    // Pin thresholds so tests are not affected by environment configuration.\n    originalStuck = process.env.STUCK_THRESHOLD_MS;\n    originalSlow = process.env.SLOW_THRESHOLD_MS;\n    process.env.STUCK_THRESHOLD_MS = '1800000'; // 30 min\n    process.env.SLOW_THRESHOLD_MS = '900000'; // 15 min\n  });\n\n  afterAll(() => {\n    if (originalStuck !== undefined) {\n      process.env.STUCK_THRESHOLD_MS = originalStuck;\n    } else {\n      delete process.env.STUCK_THRESHOLD_MS;\n    }\n    if (originalSlow !== undefined) {\n      process.env.SLOW_THRESHOLD_MS = originalSlow;\n    } else {\n      delete process.env.SLOW_THRESHOLD_MS;\n    }\n  });\n\n  // Pin thresholds so tests are independent of the runtime environment.\n  // WatchdogService reads these from process.env at instantiation time.\n  const ORIG_STUCK = process.env.STUCK_THRESHOLD_MS;\n  const ORIG_SLOW = process.env.SLOW_THRESHOLD_MS;\n\n  beforeEach(async () => {\n    process.env.STUCK_THRESHOLD_MS = '1800000'; // 30 min\n    process.env.SLOW_THRESHOLD_MS = '900000'; // 15 min\n\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        WatchdogService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByStatus: jest.fn().mockResolvedValue([]),\n            findByStatuses: jest.fn().mockResolvedValue([]),\n            save: jest\n              .fn()\n              .mockImplementation((t: TaskEntity) => Promise.resolve(t)),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn().mockResolvedValue({}),\n            findByTask: jest.fn().mockResolvedValue([]),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseBlocked: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get<WatchdogService>(WatchdogService);\n    taskStateService = module.get(TaskStateService);\n    eventService = module.get(EventService);\n    eventBus = module.get(EventBusService);\n    phaseHooks = module.get(PhaseHooksService);\n  });\n\n  afterEach(() => {\n    if (ORIG_STUCK === undefined) {\n      delete process.env.STUCK_THRESHOLD_MS;\n    } else {\n      process.env.STUCK_THRESHOLD_MS = ORIG_STUCK;\n    }\n    if (ORIG_SLOW === undefined) {\n      delete process.env.SLOW_THRESHOLD_MS;\n    } else {\n      process.env.SLOW_THRESHOLD_MS = ORIG_SLOW;\n    }\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('does nothing when no active tasks', async () => {\n    taskStateService.findByStatuses.mockResolvedValue([]);\n    await service.checkForStuckTasks();\n    expect(taskStateService.save).not.toHaveBeenCalled();\n  });\n\n  it('marks task as stuck when older than stuck threshold', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'deliver',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(task.status).toBe('stuck');\n    expect(taskStateService.save).toHaveBeenCalledWith(task);\n    expect(eventService.record).toHaveBeenCalledWith(\n      1,\n      'watchdog_detected_stuck',\n      'watchdog',\n      expect.objectContaining({ phase: 'execute' }),\n    );\n  });\n\n  it('includes lastEventAt and lastEventType in stuck event metadata', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'deliver',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    const lastEvent = {\n      id: 10,\n      taskId: 1,\n      type: 'phase_started',\n      source: 'dispatch',\n      createdAt: new Date('2026-03-23T10:00:00.000Z'),\n    } as EventEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.findByTask as jest.Mock).mockResolvedValue([lastEvent]);\n\n    await service.checkForStuckTasks();\n\n    expect(eventService.record).toHaveBeenCalledWith(\n      1,\n      'watchdog_detected_stuck',\n      'watchdog',\n      expect.objectContaining({\n        lastEventType: 'phase_started',\n        lastEventAt: '2026-03-23T10:00:00.000Z',\n      }),\n    );\n  });\n\n  it('emits watchdog_detected_slow for tasks between slow and stuck thresholds', async () => {\n    // 20 min old — past default slow threshold (15 min) but under stuck threshold (30 min)\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 20 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.countByType as jest.Mock).mockResolvedValue(0);\n\n    await service.checkForStuckTasks();\n\n    expect(task.status).toBe('active');\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(eventService.record).toHaveBeenCalledWith(\n      2,\n      'watchdog_detected_slow',\n      'watchdog',\n      expect.objectContaining({ phase: 'plan' }),\n    );\n  });\n\n  it('does not re-emit watchdog_detected_slow if already emitted', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 20 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.countByType as jest.Mock).mockResolvedValue(1);\n\n    await service.checkForStuckTasks();\n\n    expect(eventService.record).not.toHaveBeenCalled();\n  });\n\n  it('does NOT mark task as stuck when younger than threshold', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(eventService.record).not.toHaveBeenCalled();\n  });\n\n  it('does not mark stuck when last event is within the stuck threshold', async () => {\n    // updatedAt is old but a recent event was recorded (e.g. an intermediate log).\n    // The watchdog should use the most-recent of updatedAt / lastEvent.createdAt.\n    const task = {\n      id: 3,\n      issue: 20,\n      repo: 'AgentSDE/test',\n      currentPhase: 'deliver',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000), // 35 min ago\n    } as TaskEntity;\n\n    const recentEvent = {\n      id: 99,\n      taskId: 3,\n      type: 'phase_started',\n      source: 'phase-router',\n      createdAt: new Date(Date.now() - 5 * 60 * 1000), // 5 min ago\n    } as EventEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.findByTask as jest.Mock).mockResolvedValue([recentEvent]);\n\n    await service.checkForStuckTasks();\n\n    // Task should remain active — last event is recent\n    expect(task.status).toBe('active');\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(eventService.record).not.toHaveBeenCalled();\n  });\n\n  it('marks only stuck tasks when mixed ages', async () => {\n    const stuckTask = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'deliver',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 45 * 60 * 1000),\n    } as TaskEntity;\n\n    const freshTask = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([stuckTask, freshTask]);\n    await service.checkForStuckTasks();\n\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(taskStateService.save).toHaveBeenCalledWith(stuckTask);\n  });\n\n  it('emits task.stuck when a stuck task is detected', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'deliver',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(eventBus.emit).toHaveBeenCalledWith(\n      'task.stuck',\n      expect.objectContaining({\n        taskId: 1,\n        issue: 42,\n        repo: 'AgentSDE/test',\n        phase: 'execute',\n      }),\n    );\n  });\n\n  it('does not emit task.stuck for non-stuck tasks', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(eventBus.emit).not.toHaveBeenCalledWith(\n      'task.stuck',\n      expect.anything(),\n    );\n  });\n\n  it('calls onPhaseBlocked when a stuck task is detected', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'deliver',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(phaseHooks.onPhaseBlocked).toHaveBeenCalledWith(\n      expect.objectContaining({ issue: 42, repo: 'AgentSDE/test' }),\n      'execute',\n      expect.stringContaining('stuck'),\n      'stuck',\n    );\n  });\n\n  it('does not call onPhaseBlocked for non-stuck tasks', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(phaseHooks.onPhaseBlocked).not.toHaveBeenCalled();\n  });\n});\n","structuredPatch":[{"oldStart":133,"oldLines":7,"newStart":133,"newLines":7,"lines":["       1,","       'watchdog_detected_stuck',","       'watchdog',","-      expect.objectContaining({ phase: 'execute' }),","+      expect.objectContaining({ phase: 'deliver' }),","     );","   });"," "]}],"userModified":false,"replaceAll":true}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_0153J8DEdEjjctvTGzmjjp7X","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KoSLfPjX8AWZPVE3PwZWhU","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/watchdog/watchdog.service.spec.ts","offset":305,"limit":55},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":252,"cache_read_input_tokens":107550,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":252},"output_tokens":71,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"35e45302-5e25-4315-8812-1aa90f5b9724"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KoSLfPjX8AWZPVE3PwZWhU","type":"tool_result","content":"   305→    expect(eventBus.emit).toHaveBeenCalledWith(\n   306→      'task.stuck',\n   307→      expect.objectContaining({\n   308→        taskId: 1,\n   309→        issue: 42,\n   310→        repo: 'AgentSDE/test',\n   311→        phase: 'execute',\n   312→      }),\n   313→    );\n   314→  });\n   315→\n   316→  it('does not emit task.stuck for non-stuck tasks', async () => {\n   317→    const task = {\n   318→      id: 2,\n   319→      issue: 10,\n   320→      repo: 'AgentSDE/test',\n   321→      currentPhase: 'plan',\n   322→      status: 'active',\n   323→      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n   324→    } as TaskEntity;\n   325→\n   326→    taskStateService.findByStatuses.mockResolvedValue([task]);\n   327→    await service.checkForStuckTasks();\n   328→\n   329→    expect(eventBus.emit).not.toHaveBeenCalledWith(\n   330→      'task.stuck',\n   331→      expect.anything(),\n   332→    );\n   333→  });\n   334→\n   335→  it('calls onPhaseBlocked when a stuck task is detected', async () => {\n   336→    const task = {\n   337→      id: 1,\n   338→      issue: 42,\n   339→      repo: 'AgentSDE/test',\n   340→      currentPhase: 'deliver',\n   341→      status: 'active',\n   342→      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n   343→    } as TaskEntity;\n   344→\n   345→    taskStateService.findByStatuses.mockResolvedValue([task]);\n   346→    await service.checkForStuckTasks();\n   347→\n   348→    expect(phaseHooks.onPhaseBlocked).toHaveBeenCalledWith(\n   349→      expect.objectContaining({ issue: 42, repo: 'AgentSDE/test' }),\n   350→      'execute',\n   351→      expect.stringContaining('stuck'),\n   352→      'stuck',\n   353→    );\n   354→  });\n   355→\n   356→  it('does not call onPhaseBlocked for non-stuck tasks', async () => {\n   357→    const task = {\n   358→      id: 2,\n   359→      issue: 10,\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"90774c8b-bf55-4998-a498-90ecc53f4d91","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/watchdog/watchdog.service.spec.ts","content":"    expect(eventBus.emit).toHaveBeenCalledWith(\n      'task.stuck',\n      expect.objectContaining({\n        taskId: 1,\n        issue: 42,\n        repo: 'AgentSDE/test',\n        phase: 'execute',\n      }),\n    );\n  });\n\n  it('does not emit task.stuck for non-stuck tasks', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(eventBus.emit).not.toHaveBeenCalledWith(\n      'task.stuck',\n      expect.anything(),\n    );\n  });\n\n  it('calls onPhaseBlocked when a stuck task is detected', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'deliver',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(phaseHooks.onPhaseBlocked).toHaveBeenCalledWith(\n      expect.objectContaining({ issue: 42, repo: 'AgentSDE/test' }),\n      'execute',\n      expect.stringContaining('stuck'),\n      'stuck',\n    );\n  });\n\n  it('does not call onPhaseBlocked for non-stuck tasks', async () => {\n    const task = {\n      id: 2,\n      issue: 10,","numLines":55,"startLine":305,"totalLines":372}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01Lk51Wtwd3XnwpBbokjrz9w","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017tD2gFeSg9M6zFhWZphAJu","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/watchdog/watchdog.service.spec.ts","old_string":"        phase: 'execute',\n      }),\n    );\n  });\n\n  it('does not emit task.stuck for non-stuck tasks', async () => {","new_string":"        phase: 'deliver',\n      }),\n    );\n  });\n\n  it('does not emit task.stuck for non-stuck tasks', async () => {"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":897,"cache_read_input_tokens":107802,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":897},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"2e983f3e-b421-4ca9-9491-ea4f2c41fe9c"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017tD2gFeSg9M6zFhWZphAJu","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/watchdog/watchdog.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"61d12275-76ac-47cb-910b-3d92894b959d","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/watchdog/watchdog.service.spec.ts","oldString":"        phase: 'execute',\n      }),\n    );\n  });\n\n  it('does not emit task.stuck for non-stuck tasks', async () => {","newString":"        phase: 'deliver',\n      }),\n    );\n  });\n\n  it('does not emit task.stuck for non-stuck tasks', async () => {","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { WatchdogService } from './watchdog.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { EventService } from '../event/event.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventBusService } from '../event-bus';\nimport { TaskEntity } from '../database/entities';\nimport { EventEntity } from '../database/entities';\n\ndescribe('WatchdogService', () => {\n  let service: WatchdogService;\n  let taskStateService: jest.Mocked<TaskStateService>;\n  let eventService: jest.Mocked<EventService>;\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let phaseHooks: jest.Mocked<PhaseHooksService>;\n  let originalStuck: string | undefined;\n  let originalSlow: string | undefined;\n\n  beforeAll(() => {\n    // Pin thresholds so tests are not affected by environment configuration.\n    originalStuck = process.env.STUCK_THRESHOLD_MS;\n    originalSlow = process.env.SLOW_THRESHOLD_MS;\n    process.env.STUCK_THRESHOLD_MS = '1800000'; // 30 min\n    process.env.SLOW_THRESHOLD_MS = '900000'; // 15 min\n  });\n\n  afterAll(() => {\n    if (originalStuck !== undefined) {\n      process.env.STUCK_THRESHOLD_MS = originalStuck;\n    } else {\n      delete process.env.STUCK_THRESHOLD_MS;\n    }\n    if (originalSlow !== undefined) {\n      process.env.SLOW_THRESHOLD_MS = originalSlow;\n    } else {\n      delete process.env.SLOW_THRESHOLD_MS;\n    }\n  });\n\n  // Pin thresholds so tests are independent of the runtime environment.\n  // WatchdogService reads these from process.env at instantiation time.\n  const ORIG_STUCK = process.env.STUCK_THRESHOLD_MS;\n  const ORIG_SLOW = process.env.SLOW_THRESHOLD_MS;\n\n  beforeEach(async () => {\n    process.env.STUCK_THRESHOLD_MS = '1800000'; // 30 min\n    process.env.SLOW_THRESHOLD_MS = '900000'; // 15 min\n\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        WatchdogService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByStatus: jest.fn().mockResolvedValue([]),\n            findByStatuses: jest.fn().mockResolvedValue([]),\n            save: jest\n              .fn()\n              .mockImplementation((t: TaskEntity) => Promise.resolve(t)),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn().mockResolvedValue({}),\n            findByTask: jest.fn().mockResolvedValue([]),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseBlocked: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get<WatchdogService>(WatchdogService);\n    taskStateService = module.get(TaskStateService);\n    eventService = module.get(EventService);\n    eventBus = module.get(EventBusService);\n    phaseHooks = module.get(PhaseHooksService);\n  });\n\n  afterEach(() => {\n    if (ORIG_STUCK === undefined) {\n      delete process.env.STUCK_THRESHOLD_MS;\n    } else {\n      process.env.STUCK_THRESHOLD_MS = ORIG_STUCK;\n    }\n    if (ORIG_SLOW === undefined) {\n      delete process.env.SLOW_THRESHOLD_MS;\n    } else {\n      process.env.SLOW_THRESHOLD_MS = ORIG_SLOW;\n    }\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('does nothing when no active tasks', async () => {\n    taskStateService.findByStatuses.mockResolvedValue([]);\n    await service.checkForStuckTasks();\n    expect(taskStateService.save).not.toHaveBeenCalled();\n  });\n\n  it('marks task as stuck when older than stuck threshold', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'deliver',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(task.status).toBe('stuck');\n    expect(taskStateService.save).toHaveBeenCalledWith(task);\n    expect(eventService.record).toHaveBeenCalledWith(\n      1,\n      'watchdog_detected_stuck',\n      'watchdog',\n      expect.objectContaining({ phase: 'deliver' }),\n    );\n  });\n\n  it('includes lastEventAt and lastEventType in stuck event metadata', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'deliver',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    const lastEvent = {\n      id: 10,\n      taskId: 1,\n      type: 'phase_started',\n      source: 'dispatch',\n      createdAt: new Date('2026-03-23T10:00:00.000Z'),\n    } as EventEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.findByTask as jest.Mock).mockResolvedValue([lastEvent]);\n\n    await service.checkForStuckTasks();\n\n    expect(eventService.record).toHaveBeenCalledWith(\n      1,\n      'watchdog_detected_stuck',\n      'watchdog',\n      expect.objectContaining({\n        lastEventType: 'phase_started',\n        lastEventAt: '2026-03-23T10:00:00.000Z',\n      }),\n    );\n  });\n\n  it('emits watchdog_detected_slow for tasks between slow and stuck thresholds', async () => {\n    // 20 min old — past default slow threshold (15 min) but under stuck threshold (30 min)\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 20 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.countByType as jest.Mock).mockResolvedValue(0);\n\n    await service.checkForStuckTasks();\n\n    expect(task.status).toBe('active');\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(eventService.record).toHaveBeenCalledWith(\n      2,\n      'watchdog_detected_slow',\n      'watchdog',\n      expect.objectContaining({ phase: 'plan' }),\n    );\n  });\n\n  it('does not re-emit watchdog_detected_slow if already emitted', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 20 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.countByType as jest.Mock).mockResolvedValue(1);\n\n    await service.checkForStuckTasks();\n\n    expect(eventService.record).not.toHaveBeenCalled();\n  });\n\n  it('does NOT mark task as stuck when younger than threshold', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(eventService.record).not.toHaveBeenCalled();\n  });\n\n  it('does not mark stuck when last event is within the stuck threshold', async () => {\n    // updatedAt is old but a recent event was recorded (e.g. an intermediate log).\n    // The watchdog should use the most-recent of updatedAt / lastEvent.createdAt.\n    const task = {\n      id: 3,\n      issue: 20,\n      repo: 'AgentSDE/test',\n      currentPhase: 'deliver',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000), // 35 min ago\n    } as TaskEntity;\n\n    const recentEvent = {\n      id: 99,\n      taskId: 3,\n      type: 'phase_started',\n      source: 'phase-router',\n      createdAt: new Date(Date.now() - 5 * 60 * 1000), // 5 min ago\n    } as EventEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.findByTask as jest.Mock).mockResolvedValue([recentEvent]);\n\n    await service.checkForStuckTasks();\n\n    // Task should remain active — last event is recent\n    expect(task.status).toBe('active');\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(eventService.record).not.toHaveBeenCalled();\n  });\n\n  it('marks only stuck tasks when mixed ages', async () => {\n    const stuckTask = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'deliver',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 45 * 60 * 1000),\n    } as TaskEntity;\n\n    const freshTask = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([stuckTask, freshTask]);\n    await service.checkForStuckTasks();\n\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(taskStateService.save).toHaveBeenCalledWith(stuckTask);\n  });\n\n  it('emits task.stuck when a stuck task is detected', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'deliver',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(eventBus.emit).toHaveBeenCalledWith(\n      'task.stuck',\n      expect.objectContaining({\n        taskId: 1,\n        issue: 42,\n        repo: 'AgentSDE/test',\n        phase: 'execute',\n      }),\n    );\n  });\n\n  it('does not emit task.stuck for non-stuck tasks', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(eventBus.emit).not.toHaveBeenCalledWith(\n      'task.stuck',\n      expect.anything(),\n    );\n  });\n\n  it('calls onPhaseBlocked when a stuck task is detected', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'deliver',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(phaseHooks.onPhaseBlocked).toHaveBeenCalledWith(\n      expect.objectContaining({ issue: 42, repo: 'AgentSDE/test' }),\n      'execute',\n      expect.stringContaining('stuck'),\n      'stuck',\n    );\n  });\n\n  it('does not call onPhaseBlocked for non-stuck tasks', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(phaseHooks.onPhaseBlocked).not.toHaveBeenCalled();\n  });\n});\n","structuredPatch":[{"oldStart":308,"oldLines":7,"newStart":308,"newLines":7,"lines":["         taskId: 1,","         issue: 42,","         repo: 'AgentSDE/test',","-        phase: 'execute',","+        phase: 'deliver',","       }),","     );","   });"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01V3mMpQhRKpuT5eVAEr9MMC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PZDgg9a3jXdDkMdzpuZJMa","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/watchdog/watchdog.service.spec.ts","old_string":"      'execute',\n      expect.stringContaining('stuck'),\n      'stuck',","new_string":"      'deliver',\n      expect.stringContaining('stuck'),\n      'stuck',"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":279,"cache_read_input_tokens":108699,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":279},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"cac749ee-538e-4df2-9a1b-45895b50c71a"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PZDgg9a3jXdDkMdzpuZJMa","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/watchdog/watchdog.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"79be5af3-cf3d-473d-8260-657768cad978","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/watchdog/watchdog.service.spec.ts","oldString":"      'execute',\n      expect.stringContaining('stuck'),\n      'stuck',","newString":"      'deliver',\n      expect.stringContaining('stuck'),\n      'stuck',","originalFile":"/* eslint-disable @typescript-eslint/unbound-method */\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { WatchdogService } from './watchdog.service';\nimport { TaskStateService } from '../task-state/task-state.service';\nimport { EventService } from '../event/event.service';\nimport { PhaseHooksService } from '../hooks/phase-hooks.service';\nimport { EventBusService } from '../event-bus';\nimport { TaskEntity } from '../database/entities';\nimport { EventEntity } from '../database/entities';\n\ndescribe('WatchdogService', () => {\n  let service: WatchdogService;\n  let taskStateService: jest.Mocked<TaskStateService>;\n  let eventService: jest.Mocked<EventService>;\n  let eventBus: jest.Mocked<Pick<EventBusService, 'emit' | 'on'>>;\n  let phaseHooks: jest.Mocked<PhaseHooksService>;\n  let originalStuck: string | undefined;\n  let originalSlow: string | undefined;\n\n  beforeAll(() => {\n    // Pin thresholds so tests are not affected by environment configuration.\n    originalStuck = process.env.STUCK_THRESHOLD_MS;\n    originalSlow = process.env.SLOW_THRESHOLD_MS;\n    process.env.STUCK_THRESHOLD_MS = '1800000'; // 30 min\n    process.env.SLOW_THRESHOLD_MS = '900000'; // 15 min\n  });\n\n  afterAll(() => {\n    if (originalStuck !== undefined) {\n      process.env.STUCK_THRESHOLD_MS = originalStuck;\n    } else {\n      delete process.env.STUCK_THRESHOLD_MS;\n    }\n    if (originalSlow !== undefined) {\n      process.env.SLOW_THRESHOLD_MS = originalSlow;\n    } else {\n      delete process.env.SLOW_THRESHOLD_MS;\n    }\n  });\n\n  // Pin thresholds so tests are independent of the runtime environment.\n  // WatchdogService reads these from process.env at instantiation time.\n  const ORIG_STUCK = process.env.STUCK_THRESHOLD_MS;\n  const ORIG_SLOW = process.env.SLOW_THRESHOLD_MS;\n\n  beforeEach(async () => {\n    process.env.STUCK_THRESHOLD_MS = '1800000'; // 30 min\n    process.env.SLOW_THRESHOLD_MS = '900000'; // 15 min\n\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        WatchdogService,\n        {\n          provide: TaskStateService,\n          useValue: {\n            findByStatus: jest.fn().mockResolvedValue([]),\n            findByStatuses: jest.fn().mockResolvedValue([]),\n            save: jest\n              .fn()\n              .mockImplementation((t: TaskEntity) => Promise.resolve(t)),\n          },\n        },\n        {\n          provide: EventService,\n          useValue: {\n            record: jest.fn().mockResolvedValue({}),\n            findByTask: jest.fn().mockResolvedValue([]),\n            countByType: jest.fn().mockResolvedValue(0),\n          },\n        },\n        {\n          provide: EventBusService,\n          useValue: {\n            emit: jest.fn(),\n            on: jest.fn(),\n          },\n        },\n        {\n          provide: PhaseHooksService,\n          useValue: {\n            onPhaseBlocked: jest.fn().mockResolvedValue(undefined),\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get<WatchdogService>(WatchdogService);\n    taskStateService = module.get(TaskStateService);\n    eventService = module.get(EventService);\n    eventBus = module.get(EventBusService);\n    phaseHooks = module.get(PhaseHooksService);\n  });\n\n  afterEach(() => {\n    if (ORIG_STUCK === undefined) {\n      delete process.env.STUCK_THRESHOLD_MS;\n    } else {\n      process.env.STUCK_THRESHOLD_MS = ORIG_STUCK;\n    }\n    if (ORIG_SLOW === undefined) {\n      delete process.env.SLOW_THRESHOLD_MS;\n    } else {\n      process.env.SLOW_THRESHOLD_MS = ORIG_SLOW;\n    }\n  });\n\n  it('should be defined', () => {\n    expect(service).toBeDefined();\n  });\n\n  it('does nothing when no active tasks', async () => {\n    taskStateService.findByStatuses.mockResolvedValue([]);\n    await service.checkForStuckTasks();\n    expect(taskStateService.save).not.toHaveBeenCalled();\n  });\n\n  it('marks task as stuck when older than stuck threshold', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'deliver',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(task.status).toBe('stuck');\n    expect(taskStateService.save).toHaveBeenCalledWith(task);\n    expect(eventService.record).toHaveBeenCalledWith(\n      1,\n      'watchdog_detected_stuck',\n      'watchdog',\n      expect.objectContaining({ phase: 'deliver' }),\n    );\n  });\n\n  it('includes lastEventAt and lastEventType in stuck event metadata', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'deliver',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    const lastEvent = {\n      id: 10,\n      taskId: 1,\n      type: 'phase_started',\n      source: 'dispatch',\n      createdAt: new Date('2026-03-23T10:00:00.000Z'),\n    } as EventEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.findByTask as jest.Mock).mockResolvedValue([lastEvent]);\n\n    await service.checkForStuckTasks();\n\n    expect(eventService.record).toHaveBeenCalledWith(\n      1,\n      'watchdog_detected_stuck',\n      'watchdog',\n      expect.objectContaining({\n        lastEventType: 'phase_started',\n        lastEventAt: '2026-03-23T10:00:00.000Z',\n      }),\n    );\n  });\n\n  it('emits watchdog_detected_slow for tasks between slow and stuck thresholds', async () => {\n    // 20 min old — past default slow threshold (15 min) but under stuck threshold (30 min)\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 20 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.countByType as jest.Mock).mockResolvedValue(0);\n\n    await service.checkForStuckTasks();\n\n    expect(task.status).toBe('active');\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(eventService.record).toHaveBeenCalledWith(\n      2,\n      'watchdog_detected_slow',\n      'watchdog',\n      expect.objectContaining({ phase: 'plan' }),\n    );\n  });\n\n  it('does not re-emit watchdog_detected_slow if already emitted', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 20 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.countByType as jest.Mock).mockResolvedValue(1);\n\n    await service.checkForStuckTasks();\n\n    expect(eventService.record).not.toHaveBeenCalled();\n  });\n\n  it('does NOT mark task as stuck when younger than threshold', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(eventService.record).not.toHaveBeenCalled();\n  });\n\n  it('does not mark stuck when last event is within the stuck threshold', async () => {\n    // updatedAt is old but a recent event was recorded (e.g. an intermediate log).\n    // The watchdog should use the most-recent of updatedAt / lastEvent.createdAt.\n    const task = {\n      id: 3,\n      issue: 20,\n      repo: 'AgentSDE/test',\n      currentPhase: 'deliver',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000), // 35 min ago\n    } as TaskEntity;\n\n    const recentEvent = {\n      id: 99,\n      taskId: 3,\n      type: 'phase_started',\n      source: 'phase-router',\n      createdAt: new Date(Date.now() - 5 * 60 * 1000), // 5 min ago\n    } as EventEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    (eventService.findByTask as jest.Mock).mockResolvedValue([recentEvent]);\n\n    await service.checkForStuckTasks();\n\n    // Task should remain active — last event is recent\n    expect(task.status).toBe('active');\n    expect(taskStateService.save).not.toHaveBeenCalled();\n    expect(eventService.record).not.toHaveBeenCalled();\n  });\n\n  it('marks only stuck tasks when mixed ages', async () => {\n    const stuckTask = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'deliver',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 45 * 60 * 1000),\n    } as TaskEntity;\n\n    const freshTask = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([stuckTask, freshTask]);\n    await service.checkForStuckTasks();\n\n    expect(taskStateService.save).toHaveBeenCalledTimes(1);\n    expect(taskStateService.save).toHaveBeenCalledWith(stuckTask);\n  });\n\n  it('emits task.stuck when a stuck task is detected', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'deliver',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(eventBus.emit).toHaveBeenCalledWith(\n      'task.stuck',\n      expect.objectContaining({\n        taskId: 1,\n        issue: 42,\n        repo: 'AgentSDE/test',\n        phase: 'deliver',\n      }),\n    );\n  });\n\n  it('does not emit task.stuck for non-stuck tasks', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(eventBus.emit).not.toHaveBeenCalledWith(\n      'task.stuck',\n      expect.anything(),\n    );\n  });\n\n  it('calls onPhaseBlocked when a stuck task is detected', async () => {\n    const task = {\n      id: 1,\n      issue: 42,\n      repo: 'AgentSDE/test',\n      currentPhase: 'deliver',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 35 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(phaseHooks.onPhaseBlocked).toHaveBeenCalledWith(\n      expect.objectContaining({ issue: 42, repo: 'AgentSDE/test' }),\n      'execute',\n      expect.stringContaining('stuck'),\n      'stuck',\n    );\n  });\n\n  it('does not call onPhaseBlocked for non-stuck tasks', async () => {\n    const task = {\n      id: 2,\n      issue: 10,\n      repo: 'AgentSDE/test',\n      currentPhase: 'plan',\n      status: 'active',\n      updatedAt: new Date(Date.now() - 5 * 60 * 1000),\n    } as TaskEntity;\n\n    taskStateService.findByStatuses.mockResolvedValue([task]);\n    await service.checkForStuckTasks();\n\n    expect(phaseHooks.onPhaseBlocked).not.toHaveBeenCalled();\n  });\n});\n","structuredPatch":[{"oldStart":347,"oldLines":7,"newStart":347,"newLines":7,"lines":[" ","     expect(phaseHooks.onPhaseBlocked).toHaveBeenCalledWith(","       expect.objectContaining({ issue: 42, repo: 'AgentSDE/test' }),","-      'execute',","+      'deliver',","       expect.stringContaining('stuck'),","       'stuck',","     );"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01WgWZDChXwrKNLJ8488H4KL","type":"message","role":"assistant","content":[{"type":"text","text":"Now update metrics.service.spec.ts:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":524,"cache_read_input_tokens":108699,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":524},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"40bb2ca2-f8bb-4868-a3eb-37cd6a8c7ce9"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01WgWZDChXwrKNLJ8488H4KL","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Qr8ghsmAB2hu7ZCCcYDuGN","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/metrics/metrics.service.spec.ts","old_string":"          JSON.stringify({ phase: 'execute' }),\n          '2026-01-01T00:00:00.000Z',\n        ],\n      );\n\n      const result = await service.computePhaseBreakdown('30d');\n\n      expect(result.find((r) => r.phase === 'execute')).toBeUndefined();","new_string":"          JSON.stringify({ phase: 'deliver' }),\n          '2026-01-01T00:00:00.000Z',\n        ],\n      );\n\n      const result = await service.computePhaseBreakdown('30d');\n\n      expect(result.find((r) => r.phase === 'deliver')).toBeUndefined();"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":524,"cache_read_input_tokens":108699,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":524},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"80a336ec-763a-4bbd-862b-4db9dac6c9e5"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Qr8ghsmAB2hu7ZCCcYDuGN","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/metrics/metrics.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"4f1e67bc-f390-49ee-afff-a115b9834760","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/metrics/metrics.service.spec.ts","oldString":"          JSON.stringify({ phase: 'execute' }),\n          '2026-01-01T00:00:00.000Z',\n        ],\n      );\n\n      const result = await service.computePhaseBreakdown('30d');\n\n      expect(result.find((r) => r.phase === 'execute')).toBeUndefined();","newString":"          JSON.stringify({ phase: 'deliver' }),\n          '2026-01-01T00:00:00.000Z',\n        ],\n      );\n\n      const result = await service.computePhaseBreakdown('30d');\n\n      expect(result.find((r) => r.phase === 'deliver')).toBeUndefined();","originalFile":"import { Test, TestingModule } from '@nestjs/testing';\nimport { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm';\nimport { Repository, DataSource } from 'typeorm';\nimport { TaskEntity, EventEntity } from '../database';\nimport { MetricsService } from './metrics.service';\nimport { ExcludedReposService } from '../config/excluded-repos.service';\n\ndescribe('MetricsService', () => {\n  let module: TestingModule;\n  let service: MetricsService;\n  let taskRepo: Repository<TaskEntity>;\n  let dataSource: DataSource;\n\n  beforeAll(async () => {\n    module = await Test.createTestingModule({\n      imports: [\n        TypeOrmModule.forRoot({\n          type: 'better-sqlite3',\n          database: ':memory:',\n          entities: [TaskEntity, EventEntity],\n          synchronize: true,\n        }),\n        TypeOrmModule.forFeature([TaskEntity, EventEntity]),\n      ],\n      providers: [\n        MetricsService,\n        {\n          provide: ExcludedReposService,\n          useValue: {\n            getList: (): string[] => ['AgentSDE/agent-core-e2e'],\n            isExcluded: (repo: string): boolean =>\n              repo === 'AgentSDE/agent-core-e2e',\n          },\n        },\n      ],\n    }).compile();\n\n    service = module.get<MetricsService>(MetricsService);\n    taskRepo = module.get<Repository<TaskEntity>>(\n      getRepositoryToken(TaskEntity),\n    );\n    dataSource = module.get<DataSource>(DataSource);\n  });\n\n  afterAll(async () => {\n    await module.close();\n  });\n\n  beforeEach(async () => {\n    await dataSource.query('DELETE FROM events');\n    await dataSource.query('DELETE FROM tasks');\n  });\n\n  async function createTask(\n    issue: number,\n    repo: string,\n    status = 'active',\n    startedAt: Date | null = null,\n    completedAt: Date | null = null,\n  ): Promise<TaskEntity> {\n    const task = taskRepo.create({ issue, repo, status });\n    task.startedAt = startedAt;\n    task.completedAt = completedAt;\n    return taskRepo.save(task);\n  }\n\n  describe('computeSummary()', () => {\n    it('returns correct shape with zero tasks', async () => {\n      const summary = await service.computeSummary('30d');\n\n      expect(summary.total).toBe(0);\n      expect(summary.byStatus).toEqual({});\n      expect(summary.successRate).toBe(0);\n      expect(summary.activeCount).toBe(0);\n      expect(summary.inProgressCount).toBe(0);\n      expect(summary.avgCompletionMs).toBeNull();\n    });\n\n    it('counts total tasks correctly', async () => {\n      await createTask(1, 'owner/repo-a');\n      await createTask(2, 'owner/repo-b');\n      await createTask(3, 'owner/repo-c');\n\n      const summary = await service.computeSummary('30d');\n\n      expect(summary.total).toBe(3);\n    });\n\n    it('groups byStatus correctly', async () => {\n      await createTask(1, 'owner/repo-a', 'active');\n      await createTask(2, 'owner/repo-b', 'active');\n      await createTask(3, 'owner/repo-c', 'completed');\n      await createTask(4, 'owner/repo-d', 'failed');\n\n      const summary = await service.computeSummary('30d');\n\n      expect(summary.byStatus['active']).toBe(2);\n      expect(summary.byStatus['completed']).toBe(1);\n      expect(summary.byStatus['failed']).toBe(1);\n    });\n\n    it('computes successRate as completed / (completed + failed)', async () => {\n      await createTask(1, 'owner/repo-a', 'complete');\n      await createTask(2, 'owner/repo-b', 'complete');\n      await createTask(3, 'owner/repo-c', 'failed');\n\n      const summary = await service.computeSummary('30d');\n\n      expect(summary.successRate).toBeCloseTo(2 / 3);\n    });\n\n    it('returns successRate 0 when no completed or failed tasks', async () => {\n      await createTask(1, 'owner/repo-a', 'active');\n\n      const summary = await service.computeSummary('30d');\n\n      expect(summary.successRate).toBe(0);\n    });\n\n    it('returns activeCount for active tasks', async () => {\n      await createTask(1, 'owner/repo-a', 'active');\n      await createTask(2, 'owner/repo-b', 'active');\n      await createTask(3, 'owner/repo-c', 'completed');\n\n      const summary = await service.computeSummary('30d');\n\n      expect(summary.activeCount).toBe(2);\n    });\n\n    it('computes avgCompletionMs when startedAt/completedAt are set', async () => {\n      const start = new Date('2026-01-01T00:00:00.000Z');\n      const end = new Date('2026-01-01T00:01:00.000Z'); // 60_000 ms later\n\n      await createTask(1, 'owner/repo-a', 'completed', start, end);\n\n      const summary = await service.computeSummary('30d');\n\n      expect(summary.avgCompletionMs).not.toBeNull();\n      expect(summary.avgCompletionMs).toBeCloseTo(60_000, -1);\n    });\n\n    it('returns avgCompletionMs null when no tasks with both dates', async () => {\n      await createTask(1, 'owner/repo-a', 'active');\n\n      const summary = await service.computeSummary('30d');\n\n      expect(summary.avgCompletionMs).toBeNull();\n    });\n\n    it('computes successRate and inProgressCount for issue scenario (27 complete, 10 failed, 5 stopped, 3 stuck, 23 blocked, 15 gated)', async () => {\n      for (let i = 0; i < 27; i++)\n        await createTask(i + 1, 'owner/repo-a', 'complete');\n      for (let i = 0; i < 10; i++)\n        await createTask(100 + i, 'owner/repo-a', 'failed');\n      for (let i = 0; i < 5; i++)\n        await createTask(200 + i, 'owner/repo-a', 'stopped');\n      for (let i = 0; i < 3; i++)\n        await createTask(300 + i, 'owner/repo-a', 'stuck');\n      for (let i = 0; i < 23; i++)\n        await createTask(400 + i, 'owner/repo-a', 'blocked');\n      for (let i = 0; i < 15; i++)\n        await createTask(500 + i, 'owner/repo-a', 'gated');\n\n      const summary = await service.computeSummary('30d');\n\n      // denominator = 27 + 10 + 5 + 3 = 45; successRate = 27/45 ≈ 0.60\n      expect(summary.successRate).toBeCloseTo(27 / 45);\n      expect(summary.inProgressCount).toBe(38);\n    });\n\n    it('returns successRate 0 and correct inProgressCount when all tasks are blocked or gated', async () => {\n      for (let i = 0; i < 10; i++)\n        await createTask(i + 1, 'owner/repo-a', 'blocked');\n      for (let i = 0; i < 5; i++)\n        await createTask(100 + i, 'owner/repo-a', 'gated');\n\n      const summary = await service.computeSummary('30d');\n\n      expect(summary.successRate).toBe(0);\n      expect(summary.inProgressCount).toBe(15);\n      expect(summary.total).toBe(15);\n    });\n  });\n\n  describe('computeTimeseries()', () => {\n    it('returns empty array when no tasks in range', async () => {\n      const result = await service.computeTimeseries('24h', 'hour');\n      expect(Array.isArray(result)).toBe(true);\n    });\n\n    it('returns TimeseriesPoint array with bucket and count', async () => {\n      await createTask(1, 'owner/repo-a');\n\n      const result = await service.computeTimeseries('24h', 'hour');\n\n      if (result.length > 0) {\n        expect(result[0]).toHaveProperty('bucket');\n        expect(result[0]).toHaveProperty('count');\n        expect(typeof result[0].bucket).toBe('string');\n        expect(typeof result[0].count).toBe('number');\n      }\n    });\n\n    it('passes correct strftime format for 24h/hour (ISO hour bucket)', async () => {\n      await createTask(1, 'owner/repo-a');\n\n      const result = await service.computeTimeseries('24h', 'hour');\n\n      if (result.length > 0) {\n        // Format should be YYYY-MM-DDTHH:00:00\n        expect(result[0].bucket).toMatch(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:00:00$/);\n      }\n    });\n\n    it('passes correct strftime format for 7d/day (date bucket)', async () => {\n      await createTask(1, 'owner/repo-a');\n\n      const result = await service.computeTimeseries('7d', 'day');\n\n      if (result.length > 0) {\n        // Format should be YYYY-MM-DD\n        expect(result[0].bucket).toMatch(/^\\d{4}-\\d{2}-\\d{2}$/);\n      }\n    });\n\n    it('passes correct strftime format for 30d/day (date bucket)', async () => {\n      await createTask(1, 'owner/repo-a');\n\n      const result = await service.computeTimeseries('30d', 'day');\n\n      if (result.length > 0) {\n        expect(result[0].bucket).toMatch(/^\\d{4}-\\d{2}-\\d{2}$/);\n      }\n    });\n  });\n\n  describe('computePhaseBreakdown()', () => {\n    it('returns empty array when no phase events', async () => {\n      const result = await service.computePhaseBreakdown('30d');\n      expect(result).toEqual([]);\n    });\n\n    it('pairs phase_started and phase_completed events correctly', async () => {\n      const task = await createTask(1, 'owner/repo-a');\n\n      const startTime = new Date(Date.now() - 60_000); // 60s ago\n      const endTime = new Date(); // now\n\n      await dataSource.query(\n        `INSERT INTO events (task_id, type, source, data, created_at) VALUES (?, ?, ?, ?, ?)`,\n        [\n          task.id,\n          'phase_started',\n          'system',\n          JSON.stringify({ phase: 'refine' }),\n          startTime.toISOString(),\n        ],\n      );\n      await dataSource.query(\n        `INSERT INTO events (task_id, type, source, data, created_at) VALUES (?, ?, ?, ?, ?)`,\n        [\n          task.id,\n          'refine_completed',\n          'system',\n          JSON.stringify({ phase: 'refine' }),\n          endTime.toISOString(),\n        ],\n      );\n\n      const result = await service.computePhaseBreakdown('30d');\n\n      expect(result).toHaveLength(1);\n      expect(result[0].phase).toBe('refine');\n      expect(result[0].count).toBe(1);\n      expect(result[0].avgDurationMs).toBeCloseTo(60_000, -1);\n    });\n\n    it('returns PhaseBreakdown shape with phase, avgDurationMs, count', async () => {\n      const task = await createTask(1, 'owner/repo-a');\n\n      await dataSource.query(\n        `INSERT INTO events (task_id, type, source, data, created_at) VALUES (?, ?, ?, ?, ?)`,\n        [\n          task.id,\n          'phase_started',\n          'system',\n          JSON.stringify({ phase: 'plan' }),\n          new Date(Date.now() - 120_000).toISOString(),\n        ],\n      );\n      await dataSource.query(\n        `INSERT INTO events (task_id, type, source, data, created_at) VALUES (?, ?, ?, ?, ?)`,\n        [\n          task.id,\n          'plan_completed',\n          'system',\n          JSON.stringify({ phase: 'plan' }),\n          new Date().toISOString(),\n        ],\n      );\n\n      const result = await service.computePhaseBreakdown('30d');\n\n      expect(result.length).toBeGreaterThanOrEqual(1);\n      const planBreakdown = result.find((r) => r.phase === 'plan');\n      expect(planBreakdown).toBeDefined();\n      expect(planBreakdown).toHaveProperty('phase');\n      expect(planBreakdown).toHaveProperty('avgDurationMs');\n      expect(planBreakdown).toHaveProperty('count');\n    });\n\n    it('ignores phase_started events without a matching phase_completed', async () => {\n      const task = await createTask(1, 'owner/repo-a');\n\n      await dataSource.query(\n        `INSERT INTO events (task_id, type, source, data, created_at) VALUES (?, ?, ?, ?, ?)`,\n        [\n          task.id,\n          'phase_started',\n          'system',\n          JSON.stringify({ phase: 'execute' }),\n          '2026-01-01T00:00:00.000Z',\n        ],\n      );\n\n      const result = await service.computePhaseBreakdown('30d');\n\n      expect(result.find((r) => r.phase === 'execute')).toBeUndefined();\n    });\n  });\n});\n","structuredPatch":[{"oldStart":318,"oldLines":14,"newStart":318,"newLines":14,"lines":["           task.id,","           'phase_started',","           'system',","-          JSON.stringify({ phase: 'execute' }),","+          JSON.stringify({ phase: 'deliver' }),","           '2026-01-01T00:00:00.000Z',","         ],","       );"," ","       const result = await service.computePhaseBreakdown('30d');"," ","-      expect(result.find((r) => r.phase === 'execute')).toBeUndefined();","+      expect(result.find((r) => r.phase === 'deliver')).toBeUndefined();","     });","   });"," });"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01FuhpfFBBB9UJrGMvyxFozA","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me verify no remaining `execute` as a pipeline phase reference in src/:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":482,"cache_read_input_tokens":109223,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":482},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"89185e64-0a35-4a3e-9629-9cfacc5ed30b"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01FuhpfFBBB9UJrGMvyxFozA","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JQeFqoWPbCc6Eyf6hNo1pw","name":"Bash","input":{"command":"cd /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat && grep -rn \"phaseExecute\\|'execute'\\|\\\"execute\\\"\" src/ --include='*.ts' | grep -v \"//.*execute\\|execute.*method\\|re-execute\\|executePhase\\|execute the\\|to execute\\|execute phase\\|cannot execute\\|execution\\|ClaudeInvoc\\|executePhase\\|will execute\\|executes\\|already.execut\\|to-execute\\|executePhase\\|executes refine\\|execute refine\\|execute plan\\|execute and execute\" | head -60","description":"Check remaining execute references in src/"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":482,"cache_read_input_tokens":109223,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":482},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"bfa90b35-1096-4031-be29-8757e584c48d"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JQeFqoWPbCc6Eyf6hNo1pw","type":"tool_result","content":"src/event-bus/event-bus.service.spec.ts:62:        currentPhase: 'execute',\nShell cwd was reset to /home/agent/AgentSDE/agent-core","is_error":false}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"8def4a94-22a1-43fe-b159-cf6ac2b7dcab","tool_use_result":{"stdout":"src/event-bus/event-bus.service.spec.ts:62:        currentPhase: 'execute',","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_01JywGVfqgB6hGThvdW73fe9","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FmzViZhZVuLHeDoD41nydF","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/event-bus/event-bus.service.spec.ts","offset":55,"limit":20},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":311,"cache_read_input_tokens":109705,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":311},"output_tokens":73,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"75661e71-1217-4cc9-8f56-9c5fc6b6ee32"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FmzViZhZVuLHeDoD41nydF","type":"tool_result","content":"    55→  describe('emit()', () => {\n    56→    it('publishes to the correct channel with serialized payload', () => {\n    57→      const payload = {\n    58→        taskId: 1,\n    59→        issue: 42,\n    60→        repo: 'agent-core',\n    61→        status: 'active',\n    62→        currentPhase: 'execute',\n    63→        gatePhase: null,\n    64→        gateReason: null,\n    65→      };\n    66→\n    67→      service.emit('task.updated', payload);\n    68→\n    69→      expect(pubMock.publish).toHaveBeenCalledWith(\n    70→        'agentcore-prod:events:task.updated',\n    71→        JSON.stringify(payload),\n    72→      );\n    73→    });\n    74→\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"e2ce5b65-4aca-4826-8c22-caea9d214a1e","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/event-bus/event-bus.service.spec.ts","content":"  describe('emit()', () => {\n    it('publishes to the correct channel with serialized payload', () => {\n      const payload = {\n        taskId: 1,\n        issue: 42,\n        repo: 'agent-core',\n        status: 'active',\n        currentPhase: 'execute',\n        gatePhase: null,\n        gateReason: null,\n      };\n\n      service.emit('task.updated', payload);\n\n      expect(pubMock.publish).toHaveBeenCalledWith(\n        'agentcore-prod:events:task.updated',\n        JSON.stringify(payload),\n      );\n    });\n","numLines":20,"startLine":55,"totalLines":178}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01X9NLLYsCB8m3iAH6cJ2Uj8","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"This is in `event-bus.service.spec.ts`, which is not in the plan but still references `currentPhase: 'execute'`. The plan only lists specific files. Let me check if this is a problem. The plan says to replace phase references in specific files, and this file wasn't listed. But the verification step says `grep -r 'execute' src/ --include='*.ts'` returns zero hits for `execute` as a pipeline phase. This `currentPhase: 'execute'` is in a test payload and is not part of a phase-routing test per se. However, to be thorough, let me change this since it uses `execute` as a pipeline phase value, which is inconsistent with the rest of the codebase.\n\n\nThe grep verification confirms that `execute` shouldn't appear as a pipeline phase anywhere, so I need to update this test file too.","signature":"EsQIClsIDBgCKkDtfzrtKK/JhsYmbK2TiCITFJWgYF4JTwN6Sw5B+4OLxyB7DI83CXCm0Dlc0/fnkeiXjK1rIixo8eso32AEXsg+MhFjbGF1ZGUtc29ubmV0LTQtNjgAEgwlBjc8A1s2qgYcYJEaDJIsbv+fB2t8hSgkPSIwUkDUsohfABd2ouLza1DpSta3OLdE8nHrlggg0LHwoM2mnJo3UeV6kiOoAaQnxei6KpYH8kWohZyU30EXX1r19WqARDmZaWiS4rmGptJeMRBlSSHM7z196U4+zeCsLdL9V7CPQh1SkfkMiJR8OeiMDvAWA54GkSEYSMFbAGiUmC0WDvAOhGGhiJAtFQUjGvgRFLttV6oW4rIHUoIhEw4vAmt61mQdmbhpgZQqyf2rxxpQOAY0moREENCxGllCYOxPwejKwq7eU3rfpRFJUaOmbZukB12qecDZkc+Jiu0FaLScd1fTJguLWJFIC/gxRApfnVF/2aWyWHLeD7vpkML6NWM9JLnoXllssKnaVJpFF7mHqkTuZ7icBg761uE0dIx32zT9XUCrH52/ZLqK0F3jVj0AyxYommJUBsCcP5LT1OZGUgxDefR+VuAJ7Fn5CuX+OXA3mg9U2Vk7NQpCdQx0YxRtWCtcO71FTDRoLH1AynWtPPu+TO55kIp9vcQvxlzKFcf+6TPaNhUEdm4YE/VypcFJ3WAGzcdoWnUgqiGCt7fm41k/x8utE0rtFku1q9L9SdErpDi9N0D3nckwuO9+sr9J/dp/xByGOPUZP8hiUsoZo6sqHDPKPRN4msb/yF1/ClHIuZeaT5FM6TFNdXhFgZQxI+4AJAc8C2nKdS/Rem85OuLlGG3GexXiSbv9ix6IZKIzlqBMPtHtChUSWtIDL7/XUem6gC70OEmHQUJBNDC8G8ehZPihtU8AuBBrG6tyPh8jXbF+Tv/VupC9YqgDEeimgCIw8cta45DWksFMF7jsct4TMTfnMtUsi4ebsrS71us8JSO6XP5YWzVCjFZap3LLTKixFORyAXa6Hdr1Mo87BWx4NOsTF3X5f54vRHUuDv50DJPa6bpe9G5KnX8qIxPlSrI/CmIxcx1fGH2xsZnxpRNjgsnVujAfgLVp20qu8834Y015IvObPrti4HxcLdrHOY7RPfWcMisqGw+C501YUsoR2sybzmcCBa3a5X7wv0IgU2KuMMJBMOiTrAjA7AqgA4DrnEyR+2tb+kWvqsT2PDaKgtUuC3GBui0u/lJKfSZZhn/Aebp0GZiIN+0itPFWWhTP6UAOFRZ8SokTLE7ARr29Um8q0HfRp9VWpEwZmI/9p5S0FKdjM9B+Wi4mp1/GMDMAr0av382CoyWDBfXXWpkBCwjlKAYLIPXYMV542fggBRusVV5r9LbaaqjMoFWwZQSlcXF3BPm4Rrbd8EI/6TT3m279/4Tfn9JUjE5Vcn0AejekL6oDGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":752,"cache_read_input_tokens":109705,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":752},"output_tokens":10,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"21acea41-2bfc-42db-8199-59a38c9621ad"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01X9NLLYsCB8m3iAH6cJ2Uj8","type":"message","role":"assistant","content":[{"type":"text","text":"That file wasn't in the plan but uses `execute` as a phase value. Let me fix it:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":752,"cache_read_input_tokens":109705,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":752},"output_tokens":10,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"fc5c7b86-cd5c-443a-99a7-9ee6863824d7"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01X9NLLYsCB8m3iAH6cJ2Uj8","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01H9YPvQFMN1R1UBGNx5ypY9","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/event-bus/event-bus.service.spec.ts","old_string":"        currentPhase: 'execute',","new_string":"        currentPhase: 'deliver',"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":752,"cache_read_input_tokens":109705,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":752},"output_tokens":10,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"36085f37-35ae-459d-b455-42c33b852ebe"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01H9YPvQFMN1R1UBGNx5ypY9","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/event-bus/event-bus.service.spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"5addc062-73ab-49ce-af7a-17dd25f39113","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/event-bus/event-bus.service.spec.ts","oldString":"        currentPhase: 'execute',","newString":"        currentPhase: 'deliver',","originalFile":"import { Test, TestingModule } from '@nestjs/testing';\nimport { ConfigService } from '@nestjs/config';\nimport { EventBusService, REDIS_PUB, REDIS_SUB } from './event-bus.service';\n\nfunction makeMockRedis(): {\n  publish: jest.Mock;\n  subscribe: jest.Mock;\n  quit: jest.Mock;\n  on: jest.Mock;\n  _messageListener: ((channel: string, message: string) => void) | null;\n} {\n  const mock = {\n    publish: jest.fn().mockResolvedValue(1),\n    subscribe: jest.fn().mockResolvedValue(undefined),\n    quit: jest.fn().mockResolvedValue('OK'),\n    on: jest.fn(),\n    _messageListener: null as\n      | ((channel: string, message: string) => void)\n      | null,\n  };\n  mock.on.mockImplementation(\n    (event: string, listener: (channel: string, message: string) => void) => {\n      if (event === 'message') {\n        mock._messageListener = listener;\n      }\n    },\n  );\n  return mock;\n}\n\ndescribe('EventBusService', () => {\n  let service: EventBusService;\n  let pubMock: ReturnType<typeof makeMockRedis>;\n  let subMock: ReturnType<typeof makeMockRedis>;\n\n  beforeEach(async () => {\n    pubMock = makeMockRedis();\n    subMock = makeMockRedis();\n\n    const module: TestingModule = await Test.createTestingModule({\n      providers: [\n        EventBusService,\n        { provide: REDIS_PUB, useValue: pubMock },\n        { provide: REDIS_SUB, useValue: subMock },\n        {\n          provide: ConfigService,\n          useValue: { get: (_key: string, fallback: unknown) => fallback },\n        },\n      ],\n    }).compile();\n\n    service = module.get<EventBusService>(EventBusService);\n  });\n\n  describe('emit()', () => {\n    it('publishes to the correct channel with serialized payload', () => {\n      const payload = {\n        taskId: 1,\n        issue: 42,\n        repo: 'agent-core',\n        status: 'active',\n        currentPhase: 'execute',\n        gatePhase: null,\n        gateReason: null,\n      };\n\n      service.emit('task.updated', payload);\n\n      expect(pubMock.publish).toHaveBeenCalledWith(\n        'agentcore-prod:events:task.updated',\n        JSON.stringify(payload),\n      );\n    });\n\n    it('swallows Redis errors without throwing', async () => {\n      pubMock.publish.mockRejectedValue(new Error('Redis connection failed'));\n\n      expect(() =>\n        service.emit('task.updated', {\n          taskId: 1,\n          issue: 1,\n          repo: 'repo',\n          status: 'active',\n          currentPhase: 'plan',\n          gatePhase: null,\n          gateReason: null,\n        }),\n      ).not.toThrow();\n\n      // Let the rejected promise resolve\n      await new Promise((resolve) => setImmediate(resolve));\n    });\n  });\n\n  describe('on()', () => {\n    it('subscribes to the correct Redis channel', () => {\n      service.on('task.stuck', jest.fn());\n\n      expect(subMock.subscribe).toHaveBeenCalledWith(\n        'agentcore-prod:events:task.stuck',\n      );\n    });\n\n    it('dispatches deserialized payload to handler on message', () => {\n      const handler = jest.fn();\n      service.on('task.stuck', handler);\n\n      const payload = {\n        taskId: 2,\n        issue: 7,\n        repo: 'repo',\n        phase: 'plan',\n        ageMs: 3600000,\n      };\n      subMock._messageListener!(\n        'agentcore-prod:events:task.stuck',\n        JSON.stringify(payload),\n      );\n\n      expect(handler).toHaveBeenCalledWith(payload);\n    });\n\n    it('invokes multiple handlers for the same event', () => {\n      const handler1 = jest.fn();\n      const handler2 = jest.fn();\n      service.on('job.completed', handler1);\n      service.on('job.completed', handler2);\n\n      const payload = {\n        jobId: 10,\n        eventType: 'push',\n        repo: 'agent-core',\n        issueNumber: 5,\n      };\n      subMock._messageListener!(\n        'agentcore-prod:events:job.completed',\n        JSON.stringify(payload),\n      );\n\n      expect(handler1).toHaveBeenCalledWith(payload);\n      expect(handler2).toHaveBeenCalledWith(payload);\n    });\n\n    it('continues invoking remaining handlers when one throws', () => {\n      const handler1 = jest.fn().mockImplementation(() => {\n        throw new Error('handler1 failed');\n      });\n      const handler2 = jest.fn();\n      service.on('job.failed', handler1);\n      service.on('job.failed', handler2);\n\n      const payload = {\n        jobId: 11,\n        eventType: 'push',\n        repo: 'repo',\n        issueNumber: 3,\n        error: 'something went wrong',\n      };\n      subMock._messageListener!(\n        'agentcore-prod:events:job.failed',\n        JSON.stringify(payload),\n      );\n\n      expect(handler1).toHaveBeenCalled();\n      expect(handler2).toHaveBeenCalledWith(payload);\n    });\n  });\n\n  describe('onModuleDestroy()', () => {\n    it('quits both pub and sub clients', () => {\n      service.onModuleDestroy();\n\n      expect(pubMock.quit).toHaveBeenCalled();\n      expect(subMock.quit).toHaveBeenCalled();\n    });\n  });\n});\n","structuredPatch":[{"oldStart":59,"oldLines":7,"newStart":59,"newLines":7,"lines":["         issue: 42,","         repo: 'agent-core',","         status: 'active',","-        currentPhase: 'execute',","+        currentPhase: 'deliver',","         gatePhase: null,","         gateReason: null,","       };"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01YRXiXLpbcuQ8fN2XjgErs7","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me do a final check for remaining phase `execute` references:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":497,"cache_read_input_tokens":110457,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":497},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"223f7b8a-3c2c-43b4-9725-7e2fee537b90"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01YRXiXLpbcuQ8fN2XjgErs7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017Sk2FPonSdJJZXEzVY2UPY","name":"Bash","input":{"command":"cd /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat && grep -rn \"phaseExecute\\|currentPhase.*execute\\|execute.*currentPhase\\|'execute'\\|\\\"execute\\\"\" src/ --include='*.ts' | grep -v \"// \\|executePhase\\|executePh\\|execution\\|ClaudeInvo\\|to execute\\|and execute\\|execute the\\|executes\\|re-execut\\|non-execute\\|execute phase\\|cannot-execute\\|execute ref\\|execute plan\\|service.route.*execute\\|\\\"already executing\\\"\\|verb.*execute\" | grep -v \"comments/method names\" 2>/dev/null | head -30","description":"Final check for execute pipeline phase references"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":497,"cache_read_input_tokens":110457,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":497},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"a798171b-bbb6-4f0c-bb33-1ced31eb788a"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017Sk2FPonSdJJZXEzVY2UPY","type":"tool_result","content":"Shell cwd was reset to /home/agent/AgentSDE/agent-core","is_error":false}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"21524e7b-6e15-4850-8aa9-fb455943bd6a","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_01RNEo7DemjzuEpceR43MzFA","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013i7M5o3e4tMyj4wxcTUjnC","name":"Bash","input":{"command":"grep -rn \"phaseExecute\" /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/ --include='*.ts' 2>/dev/null","description":"Check for any remaining phaseExecute references"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":300,"cache_read_input_tokens":110954,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":300},"output_tokens":70,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"c74aa07c-383b-4e3d-890b-0d16062ea5eb"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013i7M5o3e4tMyj4wxcTUjnC","type":"tool_result","content":"(Bash completed with no output)","is_error":false}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"e2633313-756e-4ea0-8ec2-78468105255d","tool_use_result":{"stdout":"","stderr":"","interrupted":false,"isImage":false,"returnCodeInterpretation":"No matches found","noOutputExpected":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01Y7Jm7eyAPD6JYwMiY9WfuC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TV3ePZkcBaCtABVboBAeu2","name":"Bash","input":{"command":"grep -rn \"currentPhase.*'execute'\\|'execute'.*currentPhase\\|phase.*'execute'\\|PHASES.*execute\" /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/src/ --include='*.ts' 2>/dev/null","description":"Check for remaining execute phase string literals"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":146,"cache_read_input_tokens":111254,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":146},"output_tokens":52,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"13a005a7-4145-4abc-ab2a-d5440b50500a"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TV3ePZkcBaCtABVboBAeu2","type":"tool_result","content":"(Bash completed with no output)","is_error":false}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"cb59cf85-3c10-4ca6-8ecb-8e61d0a39033","tool_use_result":{"stdout":"","stderr":"","interrupted":false,"isImage":false,"returnCodeInterpretation":"No matches found","noOutputExpected":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01RheWXYopcA6Sf5wab1GTix","type":"message","role":"assistant","content":[{"type":"text","text":"All `phaseExecute` and `execute` phase references are cleaned up. Now let me check the git status and get the branch name:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":167,"cache_read_input_tokens":111400,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":167},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"28145727-af2a-42ce-9c51-5ed3be683143"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01RheWXYopcA6Sf5wab1GTix","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XVuU686aQuY4ER1h189nXw","name":"Bash","input":{"command":"cd /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat && git status --short && git branch --show-current","description":"Check git status and branch"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":167,"cache_read_input_tokens":111400,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":167},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"c66d6dbb-19c2-494f-b6fe-5195cfc2d54d"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XVuU686aQuY4ER1h189nXw","type":"tool_result","content":" M src/control-api/dto/phase-override.dto.ts\n M src/control-api/dto/status-override.dto.ts\n M src/database/database.module.spec.ts\n M src/database/entities/task.entity.ts\n M src/directive/directive.service.spec.ts\n M src/event-bus/event-bus.service.spec.ts\n M src/hooks/compound.service.spec.ts\n M src/hooks/phase-hooks.service.spec.ts\n M src/metrics/metrics.service.spec.ts\n M src/phase-router/phase-router.service.spec.ts\n M src/task-state/task-state.service.spec.ts\n M src/task-state/task-state.service.ts\n M src/watchdog/watchdog.service.spec.ts\nfeat/issue-501\nShell cwd was reset to /home/agent/AgentSDE/agent-core","is_error":false}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"32b7c451-054b-4854-8391-74c194191759","tool_use_result":{"stdout":" M src/control-api/dto/phase-override.dto.ts\n M src/control-api/dto/status-override.dto.ts\n M src/database/database.module.spec.ts\n M src/database/entities/task.entity.ts\n M src/directive/directive.service.spec.ts\n M src/event-bus/event-bus.service.spec.ts\n M src/hooks/compound.service.spec.ts\n M src/hooks/phase-hooks.service.spec.ts\n M src/metrics/metrics.service.spec.ts\n M src/phase-router/phase-router.service.spec.ts\n M src/task-state/task-state.service.spec.ts\n M src/task-state/task-state.service.ts\n M src/watchdog/watchdog.service.spec.ts\nfeat/issue-501","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_01KunHV4Bv6K2xGAXuvwbWpn","type":"message","role":"assistant","content":[{"type":"text","text":"Now let me run lint and tests:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":490,"cache_read_input_tokens":111567,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":490},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"c01945eb-7a2b-4796-bd82-2921def9dec7"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01KunHV4Bv6K2xGAXuvwbWpn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AyDimNW5A5pG2KnALyaXZ4","name":"Bash","input":{"command":"cd /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat && npm run lint 2>&1 | tail -20","description":"Run lint","timeout":60000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":490,"cache_read_input_tokens":111567,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":490},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"265a1165-b77f-4995-ba62-c43745c73a13"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AyDimNW5A5pG2KnALyaXZ4","type":"tool_result","content":"> agent-service@0.0.1 lint\n> eslint src/ test/ --fix\n\nsh: 1: eslint: not found\nShell cwd was reset to /home/agent/AgentSDE/agent-core","is_error":false}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"265e01a1-149d-47b7-9d83-168006ce27fc","tool_use_result":{"stdout":"> agent-service@0.0.1 lint\n> eslint src/ test/ --fix\n\nsh: 1: eslint: not found","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_0165BTbxhwCdJ1fpa2j7Zies","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Y6RHeaD9LApitiTwuBYGVX","name":"Bash","input":{"command":"cd /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat && npm install 2>&1 | tail -5 && npm run lint 2>&1 | tail -20","description":"Install dependencies and run lint","timeout":120000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":691,"cache_read_input_tokens":111567,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":691},"output_tokens":68,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"e275b9aa-a6b6-4abf-8581-e15dd77f9285"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Y6RHeaD9LApitiTwuBYGVX","type":"tool_result","content":"To address all issues, run:\n  npm audit fix\n\nRun `npm audit` for details.\n\n> agent-service@0.0.1 lint\n> eslint src/ test/ --fix\nShell cwd was reset to /home/agent/AgentSDE/agent-core","is_error":false}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"1a8a2dee-7098-4a25-ac5f-8d8000f5109c","tool_use_result":{"stdout":"To address all issues, run:\n  npm audit fix\n\nRun `npm audit` for details.\n\n> agent-service@0.0.1 lint\n> eslint src/ test/ --fix","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_01AGm4iKJ1LDW8bxWEaBVuQY","type":"message","role":"assistant","content":[{"type":"text","text":"✅ Lint passed. Now run tests:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":784,"cache_read_input_tokens":112258,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":784},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"35553513-e031-4136-ad38-f13c1516afb3"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01AGm4iKJ1LDW8bxWEaBVuQY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UD8naS7Y2dbYquG2HwsbeF","name":"Bash","input":{"command":"cd /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat && npm run test 2>&1 | tail -40","description":"Run tests","timeout":180000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":784,"cache_read_input_tokens":112258,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":784},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"528e0511-956e-46d7-9cc0-b3fd40d65da2"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UD8naS7Y2dbYquG2HwsbeF","type":"tool_result","content":"PASS src/wal/wal.e2e.spec.ts\nPASS test/integration/blocked-persistent-to-refine.integration.spec.ts\nPASS src/webhook/webhook.controller.spec.ts\nPASS test/integration/webhook-to-state.integration.spec.ts\nPASS src/conflict/conflict.service.spec.ts\nPASS src/wal/wal.service.spec.ts\nPASS src/hooks/compound.service.spec.ts\nPASS src/database/database.module.spec.ts\nPASS src/artefacts/artefacts.service.spec.ts\nPASS src/invoke/invocation-result.listener.spec.ts\nPASS src/event/event.service.spec.ts\n\u001b[31m[Nest] 3433815  - \u001b[39m04/14/2026, 9:21:35 AM \u001b[31m  ERROR\u001b[39m \u001b[38;5;3m[EventBusService] \u001b[39m\u001b[31mFailed to publish event task.updated: Redis connection failed\u001b[39m\n\u001b[31m[Nest] 3433815  - \u001b[39m04/14/2026, 9:21:35 AM \u001b[31m  ERROR\u001b[39m \u001b[38;5;3m[EventBusService] \u001b[39m\u001b[31mHandler error on event job.failed: handler1 failed\u001b[39m\nPASS src/event-bus/event-bus.service.spec.ts\nPASS src/directive/directive.controller.spec.ts\nPASS src/queue/bullmq-job-queue.spec.ts\nPASS src/dispatch/dispatch.service.spec.ts\nPASS src/control-api/controllers/tasks.controller.spec.ts\nPASS src/ws-gateway/ws-gateway.service.spec.ts\nPASS src/metrics/metrics.cache.spec.ts\nPASS src/wal/wal.controller.spec.ts\nPASS src/config/config.schema.spec.ts\nPASS src/config/excluded-repos.service.spec.ts\nPASS src/tenant/tenant-resolver.service.spec.ts\nPASS src/dispatch/job-processor.service.spec.ts\nPASS src/webhook/dto/dispatch-event.dto.spec.ts\n\u001b[31m[Nest] 3433815  - \u001b[39m04/14/2026, 9:21:37 AM \u001b[31m  ERROR\u001b[39m \u001b[38;5;3m[AdapterRegistry] \u001b[39m\u001b[31mAdapter for platform \"github\" is already registered\u001b[39m\nPASS src/webhook/adapters/adapter-registry.spec.ts\nPASS src/health/health.service.spec.ts\nPASS src/health/health.controller.spec.ts\nPASS src/llm/claude/claude-cli.provider.spec.ts\nPASS src/database/entities/webhook-delivery.entity.spec.ts\nPASS src/app.controller.spec.ts\nPASS src/database/entities/runtime-config.entity.spec.ts\n\nTest Suites: 51 passed, 51 total\nTests:       790 passed, 790 total\nSnapshots:   0 total\nTime:        12.1 s\nRan all test suites.\nShell cwd was reset to /home/agent/AgentSDE/agent-core","is_error":false}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"60c8c5c3-14b8-4fac-b984-3e13586dfb34","tool_use_result":{"stdout":"PASS src/wal/wal.e2e.spec.ts\nPASS test/integration/blocked-persistent-to-refine.integration.spec.ts\nPASS src/webhook/webhook.controller.spec.ts\nPASS test/integration/webhook-to-state.integration.spec.ts\nPASS src/conflict/conflict.service.spec.ts\nPASS src/wal/wal.service.spec.ts\nPASS src/hooks/compound.service.spec.ts\nPASS src/database/database.module.spec.ts\nPASS src/artefacts/artefacts.service.spec.ts\nPASS src/invoke/invocation-result.listener.spec.ts\nPASS src/event/event.service.spec.ts\n\u001b[31m[Nest] 3433815  - \u001b[39m04/14/2026, 9:21:35 AM \u001b[31m  ERROR\u001b[39m \u001b[38;5;3m[EventBusService] \u001b[39m\u001b[31mFailed to publish event task.updated: Redis connection failed\u001b[39m\n\u001b[31m[Nest] 3433815  - \u001b[39m04/14/2026, 9:21:35 AM \u001b[31m  ERROR\u001b[39m \u001b[38;5;3m[EventBusService] \u001b[39m\u001b[31mHandler error on event job.failed: handler1 failed\u001b[39m\nPASS src/event-bus/event-bus.service.spec.ts\nPASS src/directive/directive.controller.spec.ts\nPASS src/queue/bullmq-job-queue.spec.ts\nPASS src/dispatch/dispatch.service.spec.ts\nPASS src/control-api/controllers/tasks.controller.spec.ts\nPASS src/ws-gateway/ws-gateway.service.spec.ts\nPASS src/metrics/metrics.cache.spec.ts\nPASS src/wal/wal.controller.spec.ts\nPASS src/config/config.schema.spec.ts\nPASS src/config/excluded-repos.service.spec.ts\nPASS src/tenant/tenant-resolver.service.spec.ts\nPASS src/dispatch/job-processor.service.spec.ts\nPASS src/webhook/dto/dispatch-event.dto.spec.ts\n\u001b[31m[Nest] 3433815  - \u001b[39m04/14/2026, 9:21:37 AM \u001b[31m  ERROR\u001b[39m \u001b[38;5;3m[AdapterRegistry] \u001b[39m\u001b[31mAdapter for platform \"github\" is already registered\u001b[39m\nPASS src/webhook/adapters/adapter-registry.spec.ts\nPASS src/health/health.service.spec.ts\nPASS src/health/health.controller.spec.ts\nPASS src/llm/claude/claude-cli.provider.spec.ts\nPASS src/database/entities/webhook-delivery.entity.spec.ts\nPASS src/app.controller.spec.ts\nPASS src/database/entities/runtime-config.entity.spec.ts\n\nTest Suites: 51 passed, 51 total\nTests:       790 passed, 790 total\nSnapshots:   0 total\nTime:        12.1 s\nRan all test suites.","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_01WTtMCo72YaHuwvtpQVkMkY","type":"message","role":"assistant","content":[{"type":"text","text":"✅ 790 tests, all passing. Now generate the PR description and commit:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":942,"cache_read_input_tokens":113042,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":942},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"7aa77505-14be-427f-9774-be4b1fe63b16"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01WTtMCo72YaHuwvtpQVkMkY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012Xw7KvAFNFWBLmga4Ju6hy","name":"Bash","input":{"command":"mkdir -p /home/agent/AgentSDE/tasks/production/AgentSDE/agent-core/gh-501/changes","description":"Create changes directory"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":942,"cache_read_input_tokens":113042,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":942},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"51d4a59d-d737-448f-bedd-6e8d65116037"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012Xw7KvAFNFWBLmga4Ju6hy","type":"tool_result","content":"(Bash completed with no output)","is_error":false}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"965024b8-4d28-4057-8089-1f9948e74272","tool_use_result":{"stdout":"","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":true}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01WTtMCo72YaHuwvtpQVkMkY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017byLQUU3DFUwmsECKJQ9fX","name":"Bash","input":{"command":"cd /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat && gh issue view 501 --json title -q '.title'","description":"Get issue title"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":942,"cache_read_input_tokens":113042,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":942},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"b33fc38e-9ae0-4148-b1b8-d47fe2318422"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017byLQUU3DFUwmsECKJQ9fX","type":"tool_result","content":"Update CC APIs and database for pipeline changes (remove execute, move review after delivery)\nShell cwd was reset to /home/agent/AgentSDE/agent-core","is_error":false}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"b75f7187-97e8-4425-928b-891d67895bbc","tool_use_result":{"stdout":"Update CC APIs and database for pipeline changes (remove execute, move review after delivery)","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_01RKLyWSJRaRHAVj31pSftnd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01G69vaXXqJRn2BzyexUiamG","name":"Write","input":{"file_path":"/home/agent/AgentSDE/tasks/production/AgentSDE/agent-core/gh-501/changes/pr_description.md","content":"## Title\nfeat: implement #501 — Remove `execute` phase and align CC APIs with current pipeline\n\n## Problem\nThe `execute` phase column and its references remain in the database entity, DTOs, and test fixtures despite having been removed from `PHASE_ORDER` and routing maps. This causes API inconsistencies and misleading test fixtures.\n\n## Task / Link\nCloses #501\n\n## Changes\n- Remove `phaseExecute` column from `TaskEntity`\n- Remove `phaseExecute: 'pending'` from `createTask()` defaults in `TaskStateService`\n- Remove `'execute'` from PHASES arrays in `PhaseOverrideDto` and `StatusOverrideDto`, reorder to `[refine, plan, deliver, review, compound]`\n- Update all test fixtures across 9 spec files to replace `phaseExecute`, `currentPhase: 'execute'`, and `execute_completed`/`execute_failed` event types with valid phases (`deliver`)\n\n## Notes\n- Existing SQLite DB rows retain the `phase_execute` column harmlessly — TypeORM stops reading/writing it without a migration\n- `event-bus.service.spec.ts` (not in original plan) also had a `currentPhase: 'execute'` fixture; fixed\n\n## Testing\n- `npm run lint` — passed (zero warnings)\n- `npm run test` — 790 tests passed, 0 failures\n"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":321,"cache_read_input_tokens":113984,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":321},"output_tokens":61,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"c9f00af8-d8bb-4d26-afa3-d95b13f42233"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01G69vaXXqJRn2BzyexUiamG","type":"tool_result","content":"File created successfully at: /home/agent/AgentSDE/tasks/production/AgentSDE/agent-core/gh-501/changes/pr_description.md"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"33a27524-b667-44f1-99b0-ab30981bcc6e","tool_use_result":{"type":"create","filePath":"/home/agent/AgentSDE/tasks/production/AgentSDE/agent-core/gh-501/changes/pr_description.md","content":"## Title\nfeat: implement #501 — Remove `execute` phase and align CC APIs with current pipeline\n\n## Problem\nThe `execute` phase column and its references remain in the database entity, DTOs, and test fixtures despite having been removed from `PHASE_ORDER` and routing maps. This causes API inconsistencies and misleading test fixtures.\n\n## Task / Link\nCloses #501\n\n## Changes\n- Remove `phaseExecute` column from `TaskEntity`\n- Remove `phaseExecute: 'pending'` from `createTask()` defaults in `TaskStateService`\n- Remove `'execute'` from PHASES arrays in `PhaseOverrideDto` and `StatusOverrideDto`, reorder to `[refine, plan, deliver, review, compound]`\n- Update all test fixtures across 9 spec files to replace `phaseExecute`, `currentPhase: 'execute'`, and `execute_completed`/`execute_failed` event types with valid phases (`deliver`)\n\n## Notes\n- Existing SQLite DB rows retain the `phase_execute` column harmlessly — TypeORM stops reading/writing it without a migration\n- `event-bus.service.spec.ts` (not in original plan) also had a `currentPhase: 'execute'` fixture; fixed\n\n## Testing\n- `npm run lint` — passed (zero warnings)\n- `npm run test` — 790 tests passed, 0 failures\n","structuredPatch":[],"originalFile":null}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01EzsNTutVUQ7oRNASkTbog4","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CgtLwJxMNBMHoBuc3iqBbx","name":"Bash","input":{"command":"cd /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat && git add -A && git commit -m \"$(cat <<'EOF'\nfeat: implement #501 — remove execute phase, align CC APIs with current pipeline\n\nCo-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>\nEOF\n)\"","description":"Commit all changes"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":481,"cache_read_input_tokens":114305,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":481},"output_tokens":72,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"07d67e41-9cda-4284-b898-cec52a7e9741"}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"Exit code 1\ntest/task-state.e2e-spec.ts(48,17): error TS2339: Property 'phaseExecute' does not exist on type 'TaskEntity'.\ntest/task-state.e2e-spec.ts(127,22): error TS2339: Property 'phaseExecute' does not exist on type 'TaskEntity'.\nhusky - pre-commit script failed (code 2)","is_error":true,"tool_use_id":"toolu_01CgtLwJxMNBMHoBuc3iqBbx"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"7d19e901-39a5-4c8d-8f93-9abc13793fa8","tool_use_result":"Error: Exit code 1\ntest/task-state.e2e-spec.ts(48,17): error TS2339: Property 'phaseExecute' does not exist on type 'TaskEntity'.\ntest/task-state.e2e-spec.ts(127,22): error TS2339: Property 'phaseExecute' does not exist on type 'TaskEntity'.\nhusky - pre-commit script failed (code 2)"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01PYeTY2A48M38XUi2uzo5U4","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VZt1jWgqUwMAjL5ojVUBRW","name":"Read","input":{"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/test/task-state.e2e-spec.ts","offset":40,"limit":100},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":392,"cache_read_input_tokens":114786,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":392},"output_tokens":65,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"a96a7eb6-023f-4eb4-8017-2323ae33be7e"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01VZt1jWgqUwMAjL5ojVUBRW","type":"tool_result","content":"    40→\n    41→    expect(task.id).toBeDefined();\n    42→    expect(task.issue).toBe(42);\n    43→    expect(task.repo).toBe('AgentSDE/agent-core');\n    44→    expect(task.currentPhase).toBe('refine');\n    45→    expect(task.status).toBe('active');\n    46→    expect(task.phaseRefine).toBe('pending');\n    47→    expect(task.phasePlan).toBe('pending');\n    48→    expect(task.phaseExecute).toBe('pending');\n    49→    expect(task.phaseReview).toBe('pending');\n    50→    expect(task.phaseDeliver).toBe('pending');\n    51→    expect(task.phaseCompound).toBe('pending');\n    52→    expect(task.directive).toBe('auto');\n    53→  });\n    54→\n    55→  it('creates a task with a custom directive', async () => {\n    56→    const task = await service.createTask(43, 'AgentSDE/agent-core', 'plan');\n    57→\n    58→    expect(task.directive).toBe('plan');\n    59→  });\n    60→\n    61→  it('finds a task by issue and repo', async () => {\n    62→    await service.createTask(50, 'AgentSDE/agent-core');\n    63→\n    64→    const found = await service.findByIssueAndRepo(50, 'AgentSDE/agent-core');\n    65→    expect(found).not.toBeNull();\n    66→    expect(found!.issue).toBe(50);\n    67→  });\n    68→\n    69→  it('returns null when task is not found', async () => {\n    70→    const found = await service.findByIssueAndRepo(999, 'AgentSDE/agent-core');\n    71→    expect(found).toBeNull();\n    72→  });\n    73→\n    74→  it('finds all tasks', async () => {\n    75→    await service.createTask(10, 'org/repo-a');\n    76→    await service.createTask(20, 'org/repo-b');\n    77→    await service.createTask(30, 'org/repo-c');\n    78→\n    79→    const all = await service.findAll();\n    80→    expect(all).toHaveLength(3);\n    81→  });\n    82→\n    83→  it('finds tasks by status', async () => {\n    84→    const t1 = await service.createTask(10, 'org/repo');\n    85→    const t2 = await service.createTask(20, 'org/repo');\n    86→    t2.status = 'stopped';\n    87→    await service.save(t2);\n    88→\n    89→    const active = await service.findByStatus('active');\n    90→    expect(active).toHaveLength(1);\n    91→    expect(active[0].issue).toBe(t1.issue);\n    92→\n    93→    const stopped = await service.findByStatus('stopped');\n    94→    expect(stopped).toHaveLength(1);\n    95→    expect(stopped[0].issue).toBe(t2.issue);\n    96→  });\n    97→\n    98→  it('transitions phase status via setPhaseStatus / getPhaseStatus', async () => {\n    99→    const task = await service.createTask(60, 'org/repo');\n   100→\n   101→    expect(service.getPhaseStatus(task, 'refine')).toBe('pending');\n   102→\n   103→    service.setPhaseStatus(task, 'refine', 'running');\n   104→    await service.save(task);\n   105→\n   106→    const reloaded = await service.findByIssueAndRepo(60, 'org/repo');\n   107→    expect(reloaded!.phaseRefine).toBe('running');\n   108→  });\n   109→\n   110→  it('transitions through multiple phases', async () => {\n   111→    const task = await service.createTask(70, 'org/repo');\n   112→\n   113→    // Refine complete\n   114→    service.setPhaseStatus(task, 'refine', 'done');\n   115→    task.currentPhase = 'plan';\n   116→    await service.save(task);\n   117→\n   118→    // Plan complete\n   119→    service.setPhaseStatus(task, 'plan', 'done');\n   120→    task.currentPhase = 'execute';\n   121→    await service.save(task);\n   122→\n   123→    const reloaded = await service.findByIssueAndRepo(70, 'org/repo');\n   124→    expect(reloaded!.currentPhase).toBe('execute');\n   125→    expect(reloaded!.phaseRefine).toBe('done');\n   126→    expect(reloaded!.phasePlan).toBe('done');\n   127→    expect(reloaded!.phaseExecute).toBe('pending');\n   128→  });\n   129→\n   130→  it('sets and clears a gate', async () => {\n   131→    const task = await service.createTask(80, 'org/repo');\n   132→\n   133→    service.setGate(task, 'review', 'Awaiting human approval');\n   134→    await service.save(task);\n   135→\n   136→    let reloaded = await service.findByIssueAndRepo(80, 'org/repo');\n   137→    expect(reloaded!.status).toBe('gated');\n   138→    expect(reloaded!.gatePhase).toBe('review');\n   139→    expect(reloaded!.gateReason).toBe('Awaiting human approval');\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"c5a9bbb3-7438-4b50-b4e5-7655d29cf0af","tool_use_result":{"type":"text","file":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/test/task-state.e2e-spec.ts","content":"\n    expect(task.id).toBeDefined();\n    expect(task.issue).toBe(42);\n    expect(task.repo).toBe('AgentSDE/agent-core');\n    expect(task.currentPhase).toBe('refine');\n    expect(task.status).toBe('active');\n    expect(task.phaseRefine).toBe('pending');\n    expect(task.phasePlan).toBe('pending');\n    expect(task.phaseExecute).toBe('pending');\n    expect(task.phaseReview).toBe('pending');\n    expect(task.phaseDeliver).toBe('pending');\n    expect(task.phaseCompound).toBe('pending');\n    expect(task.directive).toBe('auto');\n  });\n\n  it('creates a task with a custom directive', async () => {\n    const task = await service.createTask(43, 'AgentSDE/agent-core', 'plan');\n\n    expect(task.directive).toBe('plan');\n  });\n\n  it('finds a task by issue and repo', async () => {\n    await service.createTask(50, 'AgentSDE/agent-core');\n\n    const found = await service.findByIssueAndRepo(50, 'AgentSDE/agent-core');\n    expect(found).not.toBeNull();\n    expect(found!.issue).toBe(50);\n  });\n\n  it('returns null when task is not found', async () => {\n    const found = await service.findByIssueAndRepo(999, 'AgentSDE/agent-core');\n    expect(found).toBeNull();\n  });\n\n  it('finds all tasks', async () => {\n    await service.createTask(10, 'org/repo-a');\n    await service.createTask(20, 'org/repo-b');\n    await service.createTask(30, 'org/repo-c');\n\n    const all = await service.findAll();\n    expect(all).toHaveLength(3);\n  });\n\n  it('finds tasks by status', async () => {\n    const t1 = await service.createTask(10, 'org/repo');\n    const t2 = await service.createTask(20, 'org/repo');\n    t2.status = 'stopped';\n    await service.save(t2);\n\n    const active = await service.findByStatus('active');\n    expect(active).toHaveLength(1);\n    expect(active[0].issue).toBe(t1.issue);\n\n    const stopped = await service.findByStatus('stopped');\n    expect(stopped).toHaveLength(1);\n    expect(stopped[0].issue).toBe(t2.issue);\n  });\n\n  it('transitions phase status via setPhaseStatus / getPhaseStatus', async () => {\n    const task = await service.createTask(60, 'org/repo');\n\n    expect(service.getPhaseStatus(task, 'refine')).toBe('pending');\n\n    service.setPhaseStatus(task, 'refine', 'running');\n    await service.save(task);\n\n    const reloaded = await service.findByIssueAndRepo(60, 'org/repo');\n    expect(reloaded!.phaseRefine).toBe('running');\n  });\n\n  it('transitions through multiple phases', async () => {\n    const task = await service.createTask(70, 'org/repo');\n\n    // Refine complete\n    service.setPhaseStatus(task, 'refine', 'done');\n    task.currentPhase = 'plan';\n    await service.save(task);\n\n    // Plan complete\n    service.setPhaseStatus(task, 'plan', 'done');\n    task.currentPhase = 'execute';\n    await service.save(task);\n\n    const reloaded = await service.findByIssueAndRepo(70, 'org/repo');\n    expect(reloaded!.currentPhase).toBe('execute');\n    expect(reloaded!.phaseRefine).toBe('done');\n    expect(reloaded!.phasePlan).toBe('done');\n    expect(reloaded!.phaseExecute).toBe('pending');\n  });\n\n  it('sets and clears a gate', async () => {\n    const task = await service.createTask(80, 'org/repo');\n\n    service.setGate(task, 'review', 'Awaiting human approval');\n    await service.save(task);\n\n    let reloaded = await service.findByIssueAndRepo(80, 'org/repo');\n    expect(reloaded!.status).toBe('gated');\n    expect(reloaded!.gatePhase).toBe('review');\n    expect(reloaded!.gateReason).toBe('Awaiting human approval');","numLines":100,"startLine":40,"totalLines":165}}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01BdAzLs19uaXAqzNQCMqtDx","type":"message","role":"assistant","content":[{"type":"text","text":"There's also an e2e spec file. Let me fix it:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2114,"cache_read_input_tokens":114786,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2114},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"bff96185-47f9-4977-bd38-6ca4afc0ff10"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01BdAzLs19uaXAqzNQCMqtDx","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01YJ62L672qKKGhJXRmPGYEK","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/test/task-state.e2e-spec.ts","old_string":"    expect(task.phaseRefine).toBe('pending');\n    expect(task.phasePlan).toBe('pending');\n    expect(task.phaseExecute).toBe('pending');\n    expect(task.phaseReview).toBe('pending');","new_string":"    expect(task.phaseRefine).toBe('pending');\n    expect(task.phasePlan).toBe('pending');\n    expect(task.phaseReview).toBe('pending');"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":2114,"cache_read_input_tokens":114786,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":2114},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"7c4f2487-1686-42a5-9508-48ece53b4fb7"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01YJ62L672qKKGhJXRmPGYEK","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/test/task-state.e2e-spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"422a0a6e-66a2-45a4-b8f2-a50b471e9ca4","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/test/task-state.e2e-spec.ts","oldString":"    expect(task.phaseRefine).toBe('pending');\n    expect(task.phasePlan).toBe('pending');\n    expect(task.phaseExecute).toBe('pending');\n    expect(task.phaseReview).toBe('pending');","newString":"    expect(task.phaseRefine).toBe('pending');\n    expect(task.phasePlan).toBe('pending');\n    expect(task.phaseReview).toBe('pending');","originalFile":"/**\n * task-state.e2e-spec.ts\n *\n * E2E tests for TaskStateService CRUD operations backed by in-memory SQLite.\n * Validates task creation, phase transitions, gating, and query methods.\n */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { TypeOrmModule } from '@nestjs/typeorm';\nimport { TaskEntity, EventEntity } from '../src/database/entities';\nimport { TaskStateService } from '../src/task-state/task-state.service';\n\ndescribe('TaskStateService (e2e with in-memory SQLite)', () => {\n  let module: TestingModule;\n  let service: TaskStateService;\n\n  beforeEach(async () => {\n    module = await Test.createTestingModule({\n      imports: [\n        TypeOrmModule.forRoot({\n          type: 'better-sqlite3',\n          database: ':memory:',\n          entities: [TaskEntity, EventEntity],\n          synchronize: true,\n        }),\n        TypeOrmModule.forFeature([TaskEntity]),\n      ],\n      providers: [TaskStateService],\n    }).compile();\n\n    service = module.get<TaskStateService>(TaskStateService);\n  });\n\n  afterEach(async () => {\n    await module.close();\n  });\n\n  it('creates a task with default phase states', async () => {\n    const task = await service.createTask(42, 'AgentSDE/agent-core');\n\n    expect(task.id).toBeDefined();\n    expect(task.issue).toBe(42);\n    expect(task.repo).toBe('AgentSDE/agent-core');\n    expect(task.currentPhase).toBe('refine');\n    expect(task.status).toBe('active');\n    expect(task.phaseRefine).toBe('pending');\n    expect(task.phasePlan).toBe('pending');\n    expect(task.phaseExecute).toBe('pending');\n    expect(task.phaseReview).toBe('pending');\n    expect(task.phaseDeliver).toBe('pending');\n    expect(task.phaseCompound).toBe('pending');\n    expect(task.directive).toBe('auto');\n  });\n\n  it('creates a task with a custom directive', async () => {\n    const task = await service.createTask(43, 'AgentSDE/agent-core', 'plan');\n\n    expect(task.directive).toBe('plan');\n  });\n\n  it('finds a task by issue and repo', async () => {\n    await service.createTask(50, 'AgentSDE/agent-core');\n\n    const found = await service.findByIssueAndRepo(50, 'AgentSDE/agent-core');\n    expect(found).not.toBeNull();\n    expect(found!.issue).toBe(50);\n  });\n\n  it('returns null when task is not found', async () => {\n    const found = await service.findByIssueAndRepo(999, 'AgentSDE/agent-core');\n    expect(found).toBeNull();\n  });\n\n  it('finds all tasks', async () => {\n    await service.createTask(10, 'org/repo-a');\n    await service.createTask(20, 'org/repo-b');\n    await service.createTask(30, 'org/repo-c');\n\n    const all = await service.findAll();\n    expect(all).toHaveLength(3);\n  });\n\n  it('finds tasks by status', async () => {\n    const t1 = await service.createTask(10, 'org/repo');\n    const t2 = await service.createTask(20, 'org/repo');\n    t2.status = 'stopped';\n    await service.save(t2);\n\n    const active = await service.findByStatus('active');\n    expect(active).toHaveLength(1);\n    expect(active[0].issue).toBe(t1.issue);\n\n    const stopped = await service.findByStatus('stopped');\n    expect(stopped).toHaveLength(1);\n    expect(stopped[0].issue).toBe(t2.issue);\n  });\n\n  it('transitions phase status via setPhaseStatus / getPhaseStatus', async () => {\n    const task = await service.createTask(60, 'org/repo');\n\n    expect(service.getPhaseStatus(task, 'refine')).toBe('pending');\n\n    service.setPhaseStatus(task, 'refine', 'running');\n    await service.save(task);\n\n    const reloaded = await service.findByIssueAndRepo(60, 'org/repo');\n    expect(reloaded!.phaseRefine).toBe('running');\n  });\n\n  it('transitions through multiple phases', async () => {\n    const task = await service.createTask(70, 'org/repo');\n\n    // Refine complete\n    service.setPhaseStatus(task, 'refine', 'done');\n    task.currentPhase = 'plan';\n    await service.save(task);\n\n    // Plan complete\n    service.setPhaseStatus(task, 'plan', 'done');\n    task.currentPhase = 'execute';\n    await service.save(task);\n\n    const reloaded = await service.findByIssueAndRepo(70, 'org/repo');\n    expect(reloaded!.currentPhase).toBe('execute');\n    expect(reloaded!.phaseRefine).toBe('done');\n    expect(reloaded!.phasePlan).toBe('done');\n    expect(reloaded!.phaseExecute).toBe('pending');\n  });\n\n  it('sets and clears a gate', async () => {\n    const task = await service.createTask(80, 'org/repo');\n\n    service.setGate(task, 'review', 'Awaiting human approval');\n    await service.save(task);\n\n    let reloaded = await service.findByIssueAndRepo(80, 'org/repo');\n    expect(reloaded!.status).toBe('gated');\n    expect(reloaded!.gatePhase).toBe('review');\n    expect(reloaded!.gateReason).toBe('Awaiting human approval');\n\n    service.clearGate(task);\n    task.status = 'active';\n    await service.save(task);\n\n    reloaded = await service.findByIssueAndRepo(80, 'org/repo');\n    expect(reloaded!.status).toBe('active');\n    expect(reloaded!.gatePhase).toBeNull();\n    expect(reloaded!.gateReason).toBeNull();\n  });\n\n  it('enforces unique constraint on issue + repo', async () => {\n    await service.createTask(90, 'org/repo');\n\n    await expect(service.createTask(90, 'org/repo')).rejects.toThrow();\n  });\n\n  it('allows same issue number for different repos', async () => {\n    await service.createTask(100, 'org/repo-a');\n    await service.createTask(100, 'org/repo-b');\n\n    const all = await service.findAll();\n    expect(all).toHaveLength(2);\n  });\n});\n","structuredPatch":[{"oldStart":45,"oldLines":7,"newStart":45,"newLines":6,"lines":["     expect(task.status).toBe('active');","     expect(task.phaseRefine).toBe('pending');","     expect(task.phasePlan).toBe('pending');","-    expect(task.phaseExecute).toBe('pending');","     expect(task.phaseReview).toBe('pending');","     expect(task.phaseDeliver).toBe('pending');","     expect(task.phaseCompound).toBe('pending');"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01TT4HAvGwvpvNydMf7v2c9S","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XTY4yFXctvwPqykoKRjxXr","name":"Edit","input":{"replace_all":false,"file_path":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/test/task-state.e2e-spec.ts","old_string":"    // Plan complete\n    service.setPhaseStatus(task, 'plan', 'done');\n    task.currentPhase = 'execute';\n    await service.save(task);\n\n    const reloaded = await service.findByIssueAndRepo(70, 'org/repo');\n    expect(reloaded!.currentPhase).toBe('execute');\n    expect(reloaded!.phaseRefine).toBe('done');\n    expect(reloaded!.phasePlan).toBe('done');\n    expect(reloaded!.phaseExecute).toBe('pending');","new_string":"    // Plan complete\n    service.setPhaseStatus(task, 'plan', 'done');\n    task.currentPhase = 'deliver';\n    await service.save(task);\n\n    const reloaded = await service.findByIssueAndRepo(70, 'org/repo');\n    expect(reloaded!.currentPhase).toBe('deliver');\n    expect(reloaded!.phaseRefine).toBe('done');\n    expect(reloaded!.phasePlan).toBe('done');\n    expect(reloaded!.phaseDeliver).toBe('pending');"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":333,"cache_read_input_tokens":116900,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":333},"output_tokens":73,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"f1de15f0-e16f-45ba-a40e-f48a44eb4453"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XTY4yFXctvwPqykoKRjxXr","type":"tool_result","content":"The file /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/test/task-state.e2e-spec.ts has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"20fb08d3-b668-49ad-b8f3-d8f4f9942f9e","tool_use_result":{"filePath":"/home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat/test/task-state.e2e-spec.ts","oldString":"    // Plan complete\n    service.setPhaseStatus(task, 'plan', 'done');\n    task.currentPhase = 'execute';\n    await service.save(task);\n\n    const reloaded = await service.findByIssueAndRepo(70, 'org/repo');\n    expect(reloaded!.currentPhase).toBe('execute');\n    expect(reloaded!.phaseRefine).toBe('done');\n    expect(reloaded!.phasePlan).toBe('done');\n    expect(reloaded!.phaseExecute).toBe('pending');","newString":"    // Plan complete\n    service.setPhaseStatus(task, 'plan', 'done');\n    task.currentPhase = 'deliver';\n    await service.save(task);\n\n    const reloaded = await service.findByIssueAndRepo(70, 'org/repo');\n    expect(reloaded!.currentPhase).toBe('deliver');\n    expect(reloaded!.phaseRefine).toBe('done');\n    expect(reloaded!.phasePlan).toBe('done');\n    expect(reloaded!.phaseDeliver).toBe('pending');","originalFile":"/**\n * task-state.e2e-spec.ts\n *\n * E2E tests for TaskStateService CRUD operations backed by in-memory SQLite.\n * Validates task creation, phase transitions, gating, and query methods.\n */\n\nimport { Test, TestingModule } from '@nestjs/testing';\nimport { TypeOrmModule } from '@nestjs/typeorm';\nimport { TaskEntity, EventEntity } from '../src/database/entities';\nimport { TaskStateService } from '../src/task-state/task-state.service';\n\ndescribe('TaskStateService (e2e with in-memory SQLite)', () => {\n  let module: TestingModule;\n  let service: TaskStateService;\n\n  beforeEach(async () => {\n    module = await Test.createTestingModule({\n      imports: [\n        TypeOrmModule.forRoot({\n          type: 'better-sqlite3',\n          database: ':memory:',\n          entities: [TaskEntity, EventEntity],\n          synchronize: true,\n        }),\n        TypeOrmModule.forFeature([TaskEntity]),\n      ],\n      providers: [TaskStateService],\n    }).compile();\n\n    service = module.get<TaskStateService>(TaskStateService);\n  });\n\n  afterEach(async () => {\n    await module.close();\n  });\n\n  it('creates a task with default phase states', async () => {\n    const task = await service.createTask(42, 'AgentSDE/agent-core');\n\n    expect(task.id).toBeDefined();\n    expect(task.issue).toBe(42);\n    expect(task.repo).toBe('AgentSDE/agent-core');\n    expect(task.currentPhase).toBe('refine');\n    expect(task.status).toBe('active');\n    expect(task.phaseRefine).toBe('pending');\n    expect(task.phasePlan).toBe('pending');\n    expect(task.phaseReview).toBe('pending');\n    expect(task.phaseDeliver).toBe('pending');\n    expect(task.phaseCompound).toBe('pending');\n    expect(task.directive).toBe('auto');\n  });\n\n  it('creates a task with a custom directive', async () => {\n    const task = await service.createTask(43, 'AgentSDE/agent-core', 'plan');\n\n    expect(task.directive).toBe('plan');\n  });\n\n  it('finds a task by issue and repo', async () => {\n    await service.createTask(50, 'AgentSDE/agent-core');\n\n    const found = await service.findByIssueAndRepo(50, 'AgentSDE/agent-core');\n    expect(found).not.toBeNull();\n    expect(found!.issue).toBe(50);\n  });\n\n  it('returns null when task is not found', async () => {\n    const found = await service.findByIssueAndRepo(999, 'AgentSDE/agent-core');\n    expect(found).toBeNull();\n  });\n\n  it('finds all tasks', async () => {\n    await service.createTask(10, 'org/repo-a');\n    await service.createTask(20, 'org/repo-b');\n    await service.createTask(30, 'org/repo-c');\n\n    const all = await service.findAll();\n    expect(all).toHaveLength(3);\n  });\n\n  it('finds tasks by status', async () => {\n    const t1 = await service.createTask(10, 'org/repo');\n    const t2 = await service.createTask(20, 'org/repo');\n    t2.status = 'stopped';\n    await service.save(t2);\n\n    const active = await service.findByStatus('active');\n    expect(active).toHaveLength(1);\n    expect(active[0].issue).toBe(t1.issue);\n\n    const stopped = await service.findByStatus('stopped');\n    expect(stopped).toHaveLength(1);\n    expect(stopped[0].issue).toBe(t2.issue);\n  });\n\n  it('transitions phase status via setPhaseStatus / getPhaseStatus', async () => {\n    const task = await service.createTask(60, 'org/repo');\n\n    expect(service.getPhaseStatus(task, 'refine')).toBe('pending');\n\n    service.setPhaseStatus(task, 'refine', 'running');\n    await service.save(task);\n\n    const reloaded = await service.findByIssueAndRepo(60, 'org/repo');\n    expect(reloaded!.phaseRefine).toBe('running');\n  });\n\n  it('transitions through multiple phases', async () => {\n    const task = await service.createTask(70, 'org/repo');\n\n    // Refine complete\n    service.setPhaseStatus(task, 'refine', 'done');\n    task.currentPhase = 'plan';\n    await service.save(task);\n\n    // Plan complete\n    service.setPhaseStatus(task, 'plan', 'done');\n    task.currentPhase = 'execute';\n    await service.save(task);\n\n    const reloaded = await service.findByIssueAndRepo(70, 'org/repo');\n    expect(reloaded!.currentPhase).toBe('execute');\n    expect(reloaded!.phaseRefine).toBe('done');\n    expect(reloaded!.phasePlan).toBe('done');\n    expect(reloaded!.phaseExecute).toBe('pending');\n  });\n\n  it('sets and clears a gate', async () => {\n    const task = await service.createTask(80, 'org/repo');\n\n    service.setGate(task, 'review', 'Awaiting human approval');\n    await service.save(task);\n\n    let reloaded = await service.findByIssueAndRepo(80, 'org/repo');\n    expect(reloaded!.status).toBe('gated');\n    expect(reloaded!.gatePhase).toBe('review');\n    expect(reloaded!.gateReason).toBe('Awaiting human approval');\n\n    service.clearGate(task);\n    task.status = 'active';\n    await service.save(task);\n\n    reloaded = await service.findByIssueAndRepo(80, 'org/repo');\n    expect(reloaded!.status).toBe('active');\n    expect(reloaded!.gatePhase).toBeNull();\n    expect(reloaded!.gateReason).toBeNull();\n  });\n\n  it('enforces unique constraint on issue + repo', async () => {\n    await service.createTask(90, 'org/repo');\n\n    await expect(service.createTask(90, 'org/repo')).rejects.toThrow();\n  });\n\n  it('allows same issue number for different repos', async () => {\n    await service.createTask(100, 'org/repo-a');\n    await service.createTask(100, 'org/repo-b');\n\n    const all = await service.findAll();\n    expect(all).toHaveLength(2);\n  });\n});\n","structuredPatch":[{"oldStart":116,"oldLines":14,"newStart":116,"newLines":14,"lines":[" ","     // Plan complete","     service.setPhaseStatus(task, 'plan', 'done');","-    task.currentPhase = 'execute';","+    task.currentPhase = 'deliver';","     await service.save(task);"," ","     const reloaded = await service.findByIssueAndRepo(70, 'org/repo');","-    expect(reloaded!.currentPhase).toBe('execute');","+    expect(reloaded!.currentPhase).toBe('deliver');","     expect(reloaded!.phaseRefine).toBe('done');","     expect(reloaded!.phasePlan).toBe('done');","-    expect(reloaded!.phaseExecute).toBe('pending');","+    expect(reloaded!.phaseDeliver).toBe('pending');","   });"," ","   it('sets and clears a gate', async () => {"]}],"userModified":false,"replaceAll":false}}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01VQTR5SZ3L1sFQ2NpYCrqK8","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FKKHEMit17AyxnL8vvAb6i","name":"Bash","input":{"command":"cd /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat && git add -A && git commit -m \"$(cat <<'EOF'\nfeat: implement #501 — remove execute phase, align CC APIs with current pipeline\n\nCo-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>\nEOF\n)\"","description":"Commit all changes"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":497,"cache_read_input_tokens":117233,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":497},"output_tokens":61,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"182981f7-05f2-4440-a4d9-a661d38e99c8"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FKKHEMit17AyxnL8vvAb6i","type":"tool_result","content":"[STARTED] Backing up original state...\n[COMPLETED] Backed up original state in git stash (e54a0df)\n[STARTED] Running tasks for staged files...\n[STARTED] package.json — 15 files\n[STARTED] *.ts — 15 files\n[STARTED] eslint --fix\n[COMPLETED] eslint --fix\n[STARTED] prettier --write\n[COMPLETED] prettier --write\n[COMPLETED] *.ts — 15 files\n[COMPLETED] package.json — 15 files\n[COMPLETED] Running tasks for staged files...\n[STARTED] Applying modifications from tasks...\n[COMPLETED] Applying modifications from tasks...\n[STARTED] Cleaning up temporary files...\n[COMPLETED] Cleaning up temporary files...\n\n> agent-service@0.0.1 test\n> jest\n\n\u001b[31m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:30 AM \u001b[31m  ERROR\u001b[39m \u001b[38;5;3m[PhaseRouterService] \u001b[39m\u001b[31mFailed to create worktree for task 1: disk full\u001b[39m\nPASS src/phase-router/phase-router.service.spec.ts\nPASS src/directive/directive.service.spec.ts\nPASS src/webhook/webhook.e2e.spec.ts\nPASS src/task-state/task-state.service.spec.ts\nPASS src/directive/directive.controller.spec.ts\nPASS src/internal-adapter/internal-adapter.service.spec.ts\nPASS src/wal/wal.e2e.spec.ts\nPASS src/webhook/adapters/github.adapter.spec.ts\nPASS src/metrics/metrics.service.spec.ts\nPASS test/integration/blocked-persistent-gated-refine.integration.spec.ts\nPASS src/control-api/controllers/tasks.controller.spec.ts\nPASS test/integration/restart-directive.integration.spec.ts\nPASS src/hooks/phase-hooks.service.spec.ts\nPASS test/integration/replan-directive.integration.spec.ts\nPASS src/operational/operational.controller.spec.ts\nPASS test/integration/blocked-persistent-to-refine.integration.spec.ts\nPASS src/watchdog/watchdog.service.spec.ts\nPASS src/wal/wal.controller.spec.ts\nPASS src/database/database.module.spec.ts\nPASS src/invoke/claude-invocation.service.spec.ts\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=plan → skill=/plan-and-propose jobId=1-plan-1776158555848 target=/tmp/agent-core-test-1776158555847-uq3pfy args=ISSUE_NUMBER=1 TASK_DIR=/tmp/agent-core-test-1776158555847-uq3pfy/tasks/1 WORKTREE_PATH=/tmp/agent-core-test-1776158555847-uq3pfy PROJECT_ROOT=/tmp/agent-core-test-1776158555847-uq3pfy\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=plan → skill=/plan-and-propose jobId=10-plan-1776158555852 target=/tmp/agent-core-test-1776158555851-3vhnhq args=ISSUE_NUMBER=10 TASK_DIR=/tmp/agent-core-test-1776158555851-3vhnhq/tasks/10 WORKTREE_PATH=/tmp/agent-core-test-1776158555851-3vhnhq PROJECT_ROOT=/tmp/agent-core-test-1776158555851-3vhnhq\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=refine → skill=/product-manager jobId=40-refine-1776158555854 target=/tmp/agent-core-test-1776158555854-3t0h8w args=ISSUE_NUMBER=40 REPO=AgentSDE/test-repo TASK_DIR=/tmp/agent-core-test-1776158555854-3t0h8w/tasks/40 WORKTREE_PATH=/tmp/agent-core-test-1776158555854-3t0h8w PROJECT_ROOT=/tmp/agent-core-test-1776158555854-3t0h8w\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=plan → skill=/plan-and-propose jobId=41-plan-1776158555857 target=/tmp/agent-core-test-1776158555856-kmi821 args=ISSUE_NUMBER=41 REPO=AgentSDE/test-repo TASK_DIR=/tmp/agent-core-test-1776158555856-kmi821/tasks/41 WORKTREE_PATH=/tmp/agent-core-test-1776158555856-kmi821 PROJECT_ROOT=/tmp/agent-core-test-1776158555856-kmi821\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=review → skill=/review-pr jobId=43-review-1776158555859 target=/tmp/agent-core-test-1776158555858-tfuqor args=ISSUE_NUMBER=43 REPO=AgentSDE/test-repo TASK_DIR=/tmp/agent-core-test-1776158555858-tfuqor/tasks/43 WORKTREE_PATH=/tmp/agent-core-test-1776158555858-tfuqor PROJECT_ROOT=/tmp/agent-core-test-1776158555858-tfuqor\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=deliver → skill=/deliver-implementation jobId=44-deliver-1776158555861 target=/tmp/agent-core-test-1776158555860-rsojmo args=ISSUE_NUMBER=44 REPO=AgentSDE/test-repo TASK_DIR=/tmp/agent-core-test-1776158555860-rsojmo/tasks/44 WORKTREE_PATH=/tmp/agent-core-test-1776158555860-rsojmo PROJECT_ROOT=/tmp/agent-core-test-1776158555860-rsojmo\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=custom-skill → skill=/custom-skill jobId=45-custom-skill-1776158555863 target=/tmp/agent-core-test-1776158555862-pdr6zj args=ISSUE_NUMBER=45 TASK_DIR=/tmp/agent-core-test-1776158555862-pdr6zj/tasks/45 WORKTREE_PATH=/tmp/agent-core-test-1776158555862-pdr6zj PROJECT_ROOT=/tmp/agent-core-test-1776158555862-pdr6zj\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=reply_to_pr_comment → skill=/reply-to-pr-comment jobId=48-reply_to_pr_comment-1776158555865 target=/tmp/agent-core-test-1776158555865-j8iign args=ISSUE_NUMBER=48 REPO=AgentSDE/test-repo TASK_DIR=/tmp/agent-core-test-1776158555865-j8iign/tasks/48 WORKTREE_PATH=/tmp/agent-core-test-1776158555865-j8iign PROJECT_ROOT=/tmp/agent-core-test-1776158555865-j8iign PR_NUMBER=100 COMMENT_ID=99999\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=plan → skill=/plan-and-propose jobId=54-plan-1776158555868 target=/tmp/agent-core-test-1776158555867-12qe2a args=ISSUE_NUMBER=42 TASK_DIR=/tmp/agent-core-test-1776158555867-12qe2a/tasks/42 WORKTREE_PATH=/tmp/agent-core-test-1776158555867-12qe2a PROJECT_ROOT=/tmp/agent-core-test-1776158555867-12qe2a DIRECTIVE=auto\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=deliver → skill=/deliver-implementation jobId=55-deliver-1776158555876 target=/tmp/agent-core-test-1776158555875-fav06q args=ISSUE_NUMBER=42 TASK_DIR=/tmp/agent-core-test-1776158555875-fav06q/tasks/42 WORKTREE_PATH=/tmp/agent-core-test-1776158555875-fav06q PROJECT_ROOT=/tmp/agent-core-test-1776158555875-fav06q PR_BASE_BRANCH=rc/v1.0\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=plan → skill=/plan-and-propose jobId=70-plan-1776158555878 target=/tmp/agent-core-test-1776158555877-99m637 args=ISSUE_NUMBER=70 REPO=AgentSDE/ai-agents-sde TASK_DIR=/tmp/agent-core-test-1776158555877-99m637/tasks/70 WORKTREE_PATH=/tmp/agent-core-test-1776158555877-99m637 PROJECT_ROOT=/tmp/agent-core-test-1776158555877-99m637 DIRECTIVE=auto\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=plan → skill=/plan-and-propose jobId=61-plan-1776158555880 target=/tmp/agent-core-test-1776158555880-n036jv args=ISSUE_NUMBER=61 REPO=AgentSDE/my-repo TASK_DIR=/tmp/agent-core-test-1776158555880-n036jv/tasks/61 WORKTREE_PATH=/tmp/agent-core-test-1776158555880-n036jv PROJECT_ROOT=/tmp/agent-core-test-1776158555880-n036jv\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=plan → skill=/plan-and-propose jobId=52-plan-1776158555883 target=/tmp/agent-core-test-1776158555882-njfss2 args=ISSUE_NUMBER=99 TASK_DIR=/tmp/agent-core-test-1776158555882-njfss2/tasks/99 WORKTREE_PATH=/tmp/agent-core-test-1776158555882-njfss2 PROJECT_ROOT=/tmp/agent-core-test-1776158555882-njfss2\u001b[39m\nPASS src/github/github.service.spec.ts\nPASS src/ws-gateway/ws-gateway.service.spec.ts\nPASS test/integration/webhook-to-state.integration.spec.ts\nPASS src/release/release.service.spec.ts\nPASS src/token-usage/token-usage.service.spec.ts\nPASS src/event/event.service.spec.ts\nPASS src/worktree/worktree-cache.service.spec.ts\nPASS src/webhook/webhook.controller.spec.ts\nPASS src/hooks/compound.service.spec.ts\nPASS src/dispatch/job-processor.service.spec.ts\nPASS src/worktree/worktree.service.spec.ts\nPASS src/conflict/conflict.service.spec.ts\nPASS src/artefacts/artefacts.service.spec.ts\n\u001b[31m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:38 AM \u001b[31m  ERROR\u001b[39m \u001b[38;5;3m[EventBusService] \u001b[39m\u001b[31mFailed to publish event task.updated: Redis connection failed\u001b[39m\n\u001b[31m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:38 AM \u001b[31m  ERROR\u001b[39m \u001b[38;5;3m[EventBusService] \u001b[39m\u001b[31mHandler error on event job.failed: handler1 failed\u001b[39m\nPASS src/event-bus/event-bus.service.spec.ts\nPASS src/metrics/metrics.cache.spec.ts\nPASS src/llm/claude/claude-cli.provider.spec.ts\nPASS src/dispatch/dispatch.service.spec.ts\nPASS src/queue/bullmq-job-queue.spec.ts\nPASS src/wal/wal.service.spec.ts\nPASS src/config/excluded-repos.service.spec.ts\nPASS src/invoke/invocation-result.listener.spec.ts\nPASS src/tenant/tenant-resolver.service.spec.ts\nPASS src/health/health.service.spec.ts\nPASS src/database/entities/runtime-config.entity.spec.ts\nPASS src/config/config.schema.spec.ts\nPASS src/health/health.controller.spec.ts\nPASS src/app.controller.spec.ts\n\u001b[31m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:39 AM \u001b[31m  ERROR\u001b[39m \u001b[38;5;3m[AdapterRegistry] \u001b[39m\u001b[31mAdapter for platform \"github\" is already registered\u001b[39m\nPASS src/webhook/adapters/adapter-registry.spec.ts\nPASS src/database/entities/webhook-delivery.entity.spec.ts\nPASS src/signal/signal-parser.spec.ts\nPASS src/webhook/dto/dispatch-event.dto.spec.ts\n\nTest Suites: 51 passed, 51 total\nTests:       790 passed, 790 total\nSnapshots:   0 total\nTime:        10.235 s, estimated 12 s\nRan all test suites.\n[feat/issue-501 10f07a8] feat: implement #501 — remove execute phase, align CC APIs with current pipeline\n 15 files changed, 61 insertions(+), 82 deletions(-)\nShell cwd was reset to /home/agent/AgentSDE/agent-core","is_error":false}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"c36e630b-eb9b-41cb-a886-6685d87b206d","tool_use_result":{"stdout":"[STARTED] Backing up original state...\n[COMPLETED] Backed up original state in git stash (e54a0df)\n[STARTED] Running tasks for staged files...\n[STARTED] package.json — 15 files\n[STARTED] *.ts — 15 files\n[STARTED] eslint --fix\n[COMPLETED] eslint --fix\n[STARTED] prettier --write\n[COMPLETED] prettier --write\n[COMPLETED] *.ts — 15 files\n[COMPLETED] package.json — 15 files\n[COMPLETED] Running tasks for staged files...\n[STARTED] Applying modifications from tasks...\n[COMPLETED] Applying modifications from tasks...\n[STARTED] Cleaning up temporary files...\n[COMPLETED] Cleaning up temporary files...\n\n> agent-service@0.0.1 test\n> jest\n\n\u001b[31m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:30 AM \u001b[31m  ERROR\u001b[39m \u001b[38;5;3m[PhaseRouterService] \u001b[39m\u001b[31mFailed to create worktree for task 1: disk full\u001b[39m\nPASS src/phase-router/phase-router.service.spec.ts\nPASS src/directive/directive.service.spec.ts\nPASS src/webhook/webhook.e2e.spec.ts\nPASS src/task-state/task-state.service.spec.ts\nPASS src/directive/directive.controller.spec.ts\nPASS src/internal-adapter/internal-adapter.service.spec.ts\nPASS src/wal/wal.e2e.spec.ts\nPASS src/webhook/adapters/github.adapter.spec.ts\nPASS src/metrics/metrics.service.spec.ts\nPASS test/integration/blocked-persistent-gated-refine.integration.spec.ts\nPASS src/control-api/controllers/tasks.controller.spec.ts\nPASS test/integration/restart-directive.integration.spec.ts\nPASS src/hooks/phase-hooks.service.spec.ts\nPASS test/integration/replan-directive.integration.spec.ts\nPASS src/operational/operational.controller.spec.ts\nPASS test/integration/blocked-persistent-to-refine.integration.spec.ts\nPASS src/watchdog/watchdog.service.spec.ts\nPASS src/wal/wal.controller.spec.ts\nPASS src/database/database.module.spec.ts\nPASS src/invoke/claude-invocation.service.spec.ts\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=plan → skill=/plan-and-propose jobId=1-plan-1776158555848 target=/tmp/agent-core-test-1776158555847-uq3pfy args=ISSUE_NUMBER=1 TASK_DIR=/tmp/agent-core-test-1776158555847-uq3pfy/tasks/1 WORKTREE_PATH=/tmp/agent-core-test-1776158555847-uq3pfy PROJECT_ROOT=/tmp/agent-core-test-1776158555847-uq3pfy\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=plan → skill=/plan-and-propose jobId=10-plan-1776158555852 target=/tmp/agent-core-test-1776158555851-3vhnhq args=ISSUE_NUMBER=10 TASK_DIR=/tmp/agent-core-test-1776158555851-3vhnhq/tasks/10 WORKTREE_PATH=/tmp/agent-core-test-1776158555851-3vhnhq PROJECT_ROOT=/tmp/agent-core-test-1776158555851-3vhnhq\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=refine → skill=/product-manager jobId=40-refine-1776158555854 target=/tmp/agent-core-test-1776158555854-3t0h8w args=ISSUE_NUMBER=40 REPO=AgentSDE/test-repo TASK_DIR=/tmp/agent-core-test-1776158555854-3t0h8w/tasks/40 WORKTREE_PATH=/tmp/agent-core-test-1776158555854-3t0h8w PROJECT_ROOT=/tmp/agent-core-test-1776158555854-3t0h8w\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=plan → skill=/plan-and-propose jobId=41-plan-1776158555857 target=/tmp/agent-core-test-1776158555856-kmi821 args=ISSUE_NUMBER=41 REPO=AgentSDE/test-repo TASK_DIR=/tmp/agent-core-test-1776158555856-kmi821/tasks/41 WORKTREE_PATH=/tmp/agent-core-test-1776158555856-kmi821 PROJECT_ROOT=/tmp/agent-core-test-1776158555856-kmi821\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=review → skill=/review-pr jobId=43-review-1776158555859 target=/tmp/agent-core-test-1776158555858-tfuqor args=ISSUE_NUMBER=43 REPO=AgentSDE/test-repo TASK_DIR=/tmp/agent-core-test-1776158555858-tfuqor/tasks/43 WORKTREE_PATH=/tmp/agent-core-test-1776158555858-tfuqor PROJECT_ROOT=/tmp/agent-core-test-1776158555858-tfuqor\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=deliver → skill=/deliver-implementation jobId=44-deliver-1776158555861 target=/tmp/agent-core-test-1776158555860-rsojmo args=ISSUE_NUMBER=44 REPO=AgentSDE/test-repo TASK_DIR=/tmp/agent-core-test-1776158555860-rsojmo/tasks/44 WORKTREE_PATH=/tmp/agent-core-test-1776158555860-rsojmo PROJECT_ROOT=/tmp/agent-core-test-1776158555860-rsojmo\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=custom-skill → skill=/custom-skill jobId=45-custom-skill-1776158555863 target=/tmp/agent-core-test-1776158555862-pdr6zj args=ISSUE_NUMBER=45 TASK_DIR=/tmp/agent-core-test-1776158555862-pdr6zj/tasks/45 WORKTREE_PATH=/tmp/agent-core-test-1776158555862-pdr6zj PROJECT_ROOT=/tmp/agent-core-test-1776158555862-pdr6zj\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=reply_to_pr_comment → skill=/reply-to-pr-comment jobId=48-reply_to_pr_comment-1776158555865 target=/tmp/agent-core-test-1776158555865-j8iign args=ISSUE_NUMBER=48 REPO=AgentSDE/test-repo TASK_DIR=/tmp/agent-core-test-1776158555865-j8iign/tasks/48 WORKTREE_PATH=/tmp/agent-core-test-1776158555865-j8iign PROJECT_ROOT=/tmp/agent-core-test-1776158555865-j8iign PR_NUMBER=100 COMMENT_ID=99999\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=plan → skill=/plan-and-propose jobId=54-plan-1776158555868 target=/tmp/agent-core-test-1776158555867-12qe2a args=ISSUE_NUMBER=42 TASK_DIR=/tmp/agent-core-test-1776158555867-12qe2a/tasks/42 WORKTREE_PATH=/tmp/agent-core-test-1776158555867-12qe2a PROJECT_ROOT=/tmp/agent-core-test-1776158555867-12qe2a DIRECTIVE=auto\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=deliver → skill=/deliver-implementation jobId=55-deliver-1776158555876 target=/tmp/agent-core-test-1776158555875-fav06q args=ISSUE_NUMBER=42 TASK_DIR=/tmp/agent-core-test-1776158555875-fav06q/tasks/42 WORKTREE_PATH=/tmp/agent-core-test-1776158555875-fav06q PROJECT_ROOT=/tmp/agent-core-test-1776158555875-fav06q PR_BASE_BRANCH=rc/v1.0\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=plan → skill=/plan-and-propose jobId=70-plan-1776158555878 target=/tmp/agent-core-test-1776158555877-99m637 args=ISSUE_NUMBER=70 REPO=AgentSDE/ai-agents-sde TASK_DIR=/tmp/agent-core-test-1776158555877-99m637/tasks/70 WORKTREE_PATH=/tmp/agent-core-test-1776158555877-99m637 PROJECT_ROOT=/tmp/agent-core-test-1776158555877-99m637 DIRECTIVE=auto\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=plan → skill=/plan-and-propose jobId=61-plan-1776158555880 target=/tmp/agent-core-test-1776158555880-n036jv args=ISSUE_NUMBER=61 REPO=AgentSDE/my-repo TASK_DIR=/tmp/agent-core-test-1776158555880-n036jv/tasks/61 WORKTREE_PATH=/tmp/agent-core-test-1776158555880-n036jv PROJECT_ROOT=/tmp/agent-core-test-1776158555880-n036jv\u001b[39m\n\u001b[32m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:35 AM \u001b[32m    LOG\u001b[39m \u001b[38;5;3m[ClaudeInvocationService] \u001b[39m\u001b[32mEnqueuing Claude job: phase=plan → skill=/plan-and-propose jobId=52-plan-1776158555883 target=/tmp/agent-core-test-1776158555882-njfss2 args=ISSUE_NUMBER=99 TASK_DIR=/tmp/agent-core-test-1776158555882-njfss2/tasks/99 WORKTREE_PATH=/tmp/agent-core-test-1776158555882-njfss2 PROJECT_ROOT=/tmp/agent-core-test-1776158555882-njfss2\u001b[39m\nPASS src/github/github.service.spec.ts\nPASS src/ws-gateway/ws-gateway.service.spec.ts\nPASS test/integration/webhook-to-state.integration.spec.ts\nPASS src/release/release.service.spec.ts\nPASS src/token-usage/token-usage.service.spec.ts\nPASS src/event/event.service.spec.ts\nPASS src/worktree/worktree-cache.service.spec.ts\nPASS src/webhook/webhook.controller.spec.ts\nPASS src/hooks/compound.service.spec.ts\nPASS src/dispatch/job-processor.service.spec.ts\nPASS src/worktree/worktree.service.spec.ts\nPASS src/conflict/conflict.service.spec.ts\nPASS src/artefacts/artefacts.service.spec.ts\n\u001b[31m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:38 AM \u001b[31m  ERROR\u001b[39m \u001b[38;5;3m[EventBusService] \u001b[39m\u001b[31mFailed to publish event task.updated: Redis connection failed\u001b[39m\n\u001b[31m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:38 AM \u001b[31m  ERROR\u001b[39m \u001b[38;5;3m[EventBusService] \u001b[39m\u001b[31mHandler error on event job.failed: handler1 failed\u001b[39m\nPASS src/event-bus/event-bus.service.spec.ts\nPASS src/metrics/metrics.cache.spec.ts\nPASS src/llm/claude/claude-cli.provider.spec.ts\nPASS src/dispatch/dispatch.service.spec.ts\nPASS src/queue/bullmq-job-queue.spec.ts\nPASS src/wal/wal.service.spec.ts\nPASS src/config/excluded-repos.service.spec.ts\nPASS src/invoke/invocation-result.listener.spec.ts\nPASS src/tenant/tenant-resolver.service.spec.ts\nPASS src/health/health.service.spec.ts\nPASS src/database/entities/runtime-config.entity.spec.ts\nPASS src/config/config.schema.spec.ts\nPASS src/health/health.controller.spec.ts\nPASS src/app.controller.spec.ts\n\u001b[31m[Nest] 3434087  - \u001b[39m04/14/2026, 9:22:39 AM \u001b[31m  ERROR\u001b[39m \u001b[38;5;3m[AdapterRegistry] \u001b[39m\u001b[31mAdapter for platform \"github\" is already registered\u001b[39m\nPASS src/webhook/adapters/adapter-registry.spec.ts\nPASS src/database/entities/webhook-delivery.entity.spec.ts\nPASS src/signal/signal-parser.spec.ts\nPASS src/webhook/dto/dispatch-event.dto.spec.ts\n\nTest Suites: 51 passed, 51 total\nTests:       790 passed, 790 total\nSnapshots:   0 total\nTime:        10.235 s, estimated 12 s\nRan all test suites.\n[feat/issue-501 10f07a8] feat: implement #501 — remove execute phase, align CC APIs with current pipeline\n 15 files changed, 61 insertions(+), 82 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_017KqdJKxiKWDcaAppRa3G1c","type":"message","role":"assistant","content":[{"type":"text","text":"✅ Committed. Now push and create the PR:"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":4363,"cache_read_input_tokens":117730,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4363},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"4fe10183-63fb-4cb1-a366-c088b31655c6"}
{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_017KqdJKxiKWDcaAppRa3G1c","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013vJ8qucsATrtS73jjq66po","name":"Bash","input":{"command":"cd /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat && git push -u origin feat/issue-501 2>&1","description":"Push branch to remote"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":4363,"cache_read_input_tokens":117730,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4363},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"9b0fa925-ab13-4d9a-a71a-25af8a12e127"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013vJ8qucsATrtS73jjq66po","type":"tool_result","content":"remote: \nremote: Create a pull request for 'feat/issue-501' on GitHub by visiting:        \nremote:      https://github.com/AgentSDE/agent-core/pull/new/feat/issue-501        \nremote: \nTo https://github.com/AgentSDE/agent-core.git\n * [new branch]      feat/issue-501 -> feat/issue-501\nbranch 'feat/issue-501' set up to track 'origin/feat/issue-501'.\nShell cwd was reset to /home/agent/AgentSDE/agent-core","is_error":false}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"7ac2635a-8ebd-48a1-87da-8b68feb706f9","tool_use_result":{"stdout":"remote: \nremote: Create a pull request for 'feat/issue-501' on GitHub by visiting:        \nremote:      https://github.com/AgentSDE/agent-core/pull/new/feat/issue-501        \nremote: \nTo https://github.com/AgentSDE/agent-core.git\n * [new branch]      feat/issue-501 -> feat/issue-501\nbranch 'feat/issue-501' set up to track 'origin/feat/issue-501'.","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_01Um6JgGXGKyzP4bUY9jdmdp","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018RLrGTEsqMZWPmYbAFjQrx","name":"Bash","input":{"command":"cd /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat && EXISTING_PR=\"$(gh pr view --json url -q '.url' 2>/dev/null || true)\" && if [[ -n \"$EXISTING_PR\" ]]; then echo \"$EXISTING_PR\"; else ISSUE_TITLE=\"$(gh issue view 501 --json title -q '.title')\" && gh pr create --title \"feat: implement #501 — ${ISSUE_TITLE}\" --body-file /home/agent/AgentSDE/tasks/production/AgentSDE/agent-core/gh-501/changes/pr_description.md --base master --reviewer zhjarvis; fi","description":"Create PR"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":280,"cache_read_input_tokens":122093,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":280},"output_tokens":60,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"52f3d9a9-7630-464e-8b6f-8176fde2dbeb"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018RLrGTEsqMZWPmYbAFjQrx","type":"tool_result","content":"https://github.com/AgentSDE/agent-core/pull/502\nShell cwd was reset to /home/agent/AgentSDE/agent-core","is_error":false}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"6ac93f26-c5ed-457e-ab73-1ef1d76de052","tool_use_result":{"stdout":"https://github.com/AgentSDE/agent-core/pull/502","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_01FViqAKYArXbV8Yzpgktscw","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JDNc1q8nw1HLuBFaaxHLoX","name":"Bash","input":{"command":"cd /home/agent/ai-agents-sde/.claude/worktrees/agent-core/issue-501-feat && gh pr comment 502 --body \"## Pre-commit Checks Passed\n\n✅ **All local checks passed for #501**\n\n- [x] Implemented planned changes\n- [x] Lint — passed\n- [x] Tests — passed (790/790)\n- [x] Committed and pushed\n\n> **PR:** #502\n\n👀 Ready for review — approve or request changes.\" && gh issue comment 501 --body \"🚀 **PR #502 opened**\n\n- [x] Implementation complete\n- [x] Pre-commit checks passed\n- [ ] CI running\n- [ ] Awaiting review\"","description":"Post PR and issue comments"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":288,"cache_read_input_tokens":122373,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":288},"output_tokens":68,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"b6faa269-a9c2-4dfb-9af4-5fac2c56246d"}
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JDNc1q8nw1HLuBFaaxHLoX","type":"tool_result","content":"https://github.com/AgentSDE/agent-core/pull/502#issuecomment-4242770082\nhttps://github.com/AgentSDE/agent-core/issues/501#issuecomment-4242770211\nShell cwd was reset to /home/agent/AgentSDE/agent-core","is_error":false}]},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"2d1119cc-c8b7-4302-afd6-36e2bd72a09b","tool_use_result":{"stdout":"https://github.com/AgentSDE/agent-core/pull/502#issuecomment-4242770082\nhttps://github.com/AgentSDE/agent-core/issues/501#issuecomment-4242770211","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_01TbWEcQe4yKCecVbX8CafSX","type":"message","role":"assistant","content":[{"type":"text","text":"🚀 **PR #502 is live!**\n\nEverything shipped cleanly:\n\n- ✅ Removed `phaseExecute` column from `TaskEntity` and `createTask()` defaults\n- ✅ Removed `'execute'` from both DTO PHASES arrays, reordered to `[refine, plan, deliver, review, compound]`\n- ✅ Updated all test fixtures across 13 spec files (including `test/task-state.e2e-spec.ts` caught by pre-commit hook and `src/event-bus/event-bus.service.spec.ts`)\n- ✅ Lint passed, 790/790 tests passed\n- 🔧 Pre-commit hook caught two missed `phaseExecute` references in the e2e spec — fixed before the commit landed\n\nPR: https://github.com/AgentSDE/agent-core/pull/502\n\n<promise>COMPLETE</promise>"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":333,"cache_read_input_tokens":122661,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":333},"output_tokens":10,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":null,"session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","uuid":"c440ee8c-9150-47ac-9d94-cb5666229876"}
{"type":"result","subtype":"success","is_error":false,"duration_ms":408713,"duration_api_ms":331173,"num_turns":85,"result":"🚀 **PR #502 is live!**\n\nEverything shipped cleanly:\n\n- ✅ Removed `phaseExecute` column from `TaskEntity` and `createTask()` defaults\n- ✅ Removed `'execute'` from both DTO PHASES arrays, reordered to `[refine, plan, deliver, review, compound]`\n- ✅ Updated all test fixtures across 13 spec files (including `test/task-state.e2e-spec.ts` caught by pre-commit hook and `src/event-bus/event-bus.service.spec.ts`)\n- ✅ Lint passed, 790/790 tests passed\n- 🔧 Pre-commit hook caught two missed `phaseExecute` references in the e2e spec — fixed before the commit landed\n\nPR: https://github.com/AgentSDE/agent-core/pull/502\n\n<promise>COMPLETE</promise>","stop_reason":"end_turn","session_id":"c3fe1ced-7e75-4a76-b6dc-d45da0129056","total_cost_usd":2.7260241,"usage":{"input_tokens":68,"cache_creation_input_tokens":110898,"cache_read_input_tokens":6639742,"output_tokens":21202,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":110898,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"},"modelUsage":{"claude-sonnet-4-6":{"inputTokens":68,"outputTokens":21202,"cacheReadInputTokens":6639742,"cacheCreationInputTokens":110898,"webSearchRequests":0,"costUSD":2.7260241,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"fast_mode_state":"off","uuid":"5af437d1-078e-46cf-9206-48f12e2e9478"}