diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 71bcc0c..d8b1540 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -38,6 +38,10 @@ vi.mock('./paired-execution-context.js', () => ({ completePairedExecutionContext: vi.fn(), markRoomReviewReady: vi.fn(() => null), formatRoomReviewReadyMessage: vi.fn(() => null), + setRoomTaskRiskLevel: vi.fn(() => null), + recordRoomPlan: vi.fn(() => null), + approveRoomPlan: vi.fn(() => null), + requestRoomPlanChanges: vi.fn(() => null), })); vi.mock('./db.js', () => { @@ -312,6 +316,249 @@ describe('createMessageRuntime', () => { expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, pendingMessage); }); + it('surfaces the high-risk plan gate message through the message-runtime /review path', async () => { + const chatJid = 'group@test'; + const group = { + ...makeGroup('codex'), + workDir: '/repo/canonical', + }; + const channel = makeChannel(chatJid); + const saveState = vi.fn(); + const lastAgentTimestamps: Record = {}; + const blockedMessage = [ + 'Plan review is required before formal review for this high-risk task.', + '- Task: paired-task-1', + '- Plan status: pending', + 'Ask the owner to record a plan and have the reviewer approve it before /review.', + ].join('\n'); + + vi.mocked(db.isPairedRoomJid).mockReturnValue(true); + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'msg-review', + chat_jid: chatJid, + sender: 'me@test', + sender_name: 'Me', + content: '/review', + timestamp: '2026-03-29T00:00:00.000Z', + is_from_me: true, + }, + ]); + const actualSessionCommands = + await vi.importActual( + './session-commands.js', + ); + vi.mocked(sessionCommands.handleSessionCommand).mockImplementation((opts) => + actualSessionCommands.handleSessionCommand(opts), + ); + vi.mocked(pairedExecutionContext.markRoomReviewReady).mockReturnValue({ + status: 'blocked', + task: { + id: 'paired-task-1', + chat_jid: chatJid, + group_folder: group.folder, + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + title: null, + source_ref: 'HEAD', + task_policy: 'autonomous', + risk_level: 'high', + plan_status: 'pending', + review_requested_at: null, + status: 'plan_review_pending', + created_at: '2026-03-29T00:00:00.000Z', + updated_at: '2026-03-29T00:00:00.000Z', + }, + blockedReason: 'plan-review-required', + }); + vi.mocked( + pairedExecutionContext.formatRoomReviewReadyMessage, + ).mockReturnValue(blockedMessage); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [channel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => lastAgentTimestamps, + saveState, + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-review-blocked', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, blockedMessage); + }); + + it('routes /approve-plan through reviewer context in the message-runtime path', async () => { + const chatJid = 'group@test'; + const group = { + ...makeGroup('codex'), + workDir: '/repo/canonical', + }; + const channel = makeChannel(chatJid); + const saveState = vi.fn(); + const lastAgentTimestamps: Record = {}; + + vi.mocked(db.isPairedRoomJid).mockReturnValue(true); + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'msg-approve-plan', + chat_jid: chatJid, + sender: 'me@test', + sender_name: 'Me', + content: '/approve-plan', + timestamp: '2026-03-29T00:00:00.000Z', + is_from_me: true, + }, + ]); + const actualSessionCommands = + await vi.importActual( + './session-commands.js', + ); + vi.mocked(sessionCommands.handleSessionCommand).mockImplementation((opts) => + actualSessionCommands.handleSessionCommand(opts), + ); + vi.mocked(pairedExecutionContext.approveRoomPlan).mockReturnValue( + 'Plan approved.\n- Task: paired-task-1', + ); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [channel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => lastAgentTimestamps, + saveState, + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-approve-plan', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(pairedExecutionContext.approveRoomPlan).toHaveBeenCalledWith( + expect.objectContaining({ + chatJid, + roomRoleContext: expect.objectContaining({ + role: 'reviewer', + serviceId: 'codex-main', + }), + }), + ); + expect(channel.sendMessage).toHaveBeenCalledWith( + chatJid, + 'Plan approved.\n- Task: paired-task-1', + ); + }); + + it('routes /request-plan-changes through reviewer context in the message-runtime path', async () => { + const chatJid = 'group@test'; + const group = { + ...makeGroup('codex'), + workDir: '/repo/canonical', + }; + const channel = makeChannel(chatJid); + const saveState = vi.fn(); + const lastAgentTimestamps: Record = {}; + + vi.mocked(db.isPairedRoomJid).mockReturnValue(true); + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'msg-request-plan-changes', + chat_jid: chatJid, + sender: 'me@test', + sender_name: 'Me', + content: '/request-plan-changes tighten scope', + timestamp: '2026-03-29T00:00:00.000Z', + is_from_me: true, + }, + ]); + const actualSessionCommands = + await vi.importActual( + './session-commands.js', + ); + vi.mocked(sessionCommands.handleSessionCommand).mockImplementation((opts) => + actualSessionCommands.handleSessionCommand(opts), + ); + vi.mocked(pairedExecutionContext.requestRoomPlanChanges).mockReturnValue( + 'Plan changes requested.\n- Task: paired-task-1', + ); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [channel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => lastAgentTimestamps, + saveState, + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-request-plan-changes', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(pairedExecutionContext.requestRoomPlanChanges).toHaveBeenCalledWith( + expect.objectContaining({ + chatJid, + roomRoleContext: expect.objectContaining({ + role: 'reviewer', + serviceId: 'codex-main', + }), + note: 'tighten scope', + }), + ); + expect(channel.sendMessage).toHaveBeenCalledWith( + chatJid, + 'Plan changes requested.\n- Task: paired-task-1', + ); + }); + it('ignores generic failure bot messages in paired rooms', async () => { const chatJid = 'group@test'; const group = makeGroup('codex'); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 9461d79..e4209bf 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -42,8 +42,12 @@ import { runAgentForGroup } from './message-agent-executor.js'; import { MessageTurnController } from './message-turn-controller.js'; import { createSuppressToken } from './output-suppression.js'; import { + approveRoomPlan, formatRoomReviewReadyMessage, markRoomReviewReady, + recordRoomPlan, + requestRoomPlanChanges, + setRoomTaskRiskLevel, } from './paired-execution-context.js'; import { buildRoomRoleContext } from './room-role-context.js'; import { @@ -565,6 +569,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist()))) ); }, + // Session commands are explicit control events, so owner-only and + // reviewer-only commands resolve against the target role rather than + // whichever service happened to pick up the slash command first. markReviewReady: async () => { const lease = getEffectiveChannelLease(chatJid); const roomRoleContext = buildRoomRoleContext( @@ -578,6 +585,57 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { }); return formatRoomReviewReadyMessage(result); }, + setTaskRiskLevel: async (riskLevel) => { + const lease = getEffectiveChannelLease(chatJid); + const roomRoleContext = buildRoomRoleContext( + lease, + lease.owner_service_id, + ); + return setRoomTaskRiskLevel({ + group, + chatJid, + roomRoleContext, + riskLevel, + }); + }, + recordPlan: async (plan) => { + const lease = getEffectiveChannelLease(chatJid); + const roomRoleContext = buildRoomRoleContext( + lease, + lease.owner_service_id, + ); + return recordRoomPlan({ + group, + chatJid, + roomRoleContext, + ...plan, + }); + }, + approvePlan: async () => { + const lease = getEffectiveChannelLease(chatJid); + const roomRoleContext = buildRoomRoleContext( + lease, + lease.reviewer_service_id ?? lease.owner_service_id, + ); + return approveRoomPlan({ + group, + chatJid, + roomRoleContext, + }); + }, + requestPlanChanges: async (note) => { + const lease = getEffectiveChannelLease(chatJid); + const roomRoleContext = buildRoomRoleContext( + lease, + lease.reviewer_service_id ?? lease.owner_service_id, + ); + return requestRoomPlanChanges({ + group, + chatJid, + roomRoleContext, + note, + }); + }, }, }); if (cmdResult.handled) return cmdResult.success; diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index 4f8f02a..b4d0d2d 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -1,18 +1,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('./db.js', () => ({ + createPairedArtifact: vi.fn(), createPairedExecution: vi.fn(), createPairedTask: vi.fn(), getLatestOpenPairedTaskForChat: vi.fn(), getPairedExecutionById: vi.fn(), getPairedTaskById: vi.fn(), getPairedWorkspace: vi.fn(), + listPairedArtifactsForTask: vi.fn(), updatePairedExecution: vi.fn(), updatePairedTask: vi.fn(), upsertPairedProject: vi.fn(), })); vi.mock('./paired-workspace-manager.js', () => ({ + PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE: + 'Plan review is required before formal review for this high-risk task.', markPairedTaskReviewReady: vi.fn(), prepareReviewerWorkspaceForExecution: vi.fn(), provisionOwnerWorkspaceForPairedTask: vi.fn(), @@ -29,9 +33,13 @@ vi.mock('./logger.js', () => ({ import * as db from './db.js'; import { + approveRoomPlan, formatRoomReviewReadyMessage, markRoomReviewReady, preparePairedExecutionContext, + recordRoomPlan, + requestRoomPlanChanges, + setRoomTaskRiskLevel, } from './paired-execution-context.js'; import * as pairedWorkspaceManager from './paired-workspace-manager.js'; import type { RegisteredGroup, RoomRoleContext } from './types.js'; @@ -68,6 +76,7 @@ describe('paired execution context', () => { vi.mocked(db.getPairedExecutionById).mockReturnValue(undefined); vi.mocked(db.getPairedTaskById).mockReturnValue(undefined); vi.mocked(db.getPairedWorkspace).mockReturnValue(undefined); + vi.mocked(db.listPairedArtifactsForTask).mockReturnValue([]); vi.mocked( pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask, ).mockReturnValue({ @@ -408,4 +417,321 @@ describe('paired execution context', () => { ].join('\n'), ); }); + + it('blocks /review for high-risk tasks until the plan is approved', () => { + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ + id: 'task-1', + chat_jid: 'dc:test', + group_folder: group.folder, + owner_service_id: 'codex-main', + reviewer_service_id: 'codex-review', + title: null, + source_ref: 'HEAD', + task_policy: 'autonomous', + risk_level: 'high', + plan_status: 'pending', + review_requested_at: null, + status: 'plan_review_pending', + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:00:00.000Z', + }); + + const result = markRoomReviewReady({ + group, + chatJid: 'dc:test', + roomRoleContext: ownerContext, + }); + + expect(pairedWorkspaceManager.markPairedTaskReviewReady).not.toHaveBeenCalled(); + expect(result).toEqual({ + status: 'blocked', + task: expect.objectContaining({ + id: 'task-1', + risk_level: 'high', + plan_status: 'pending', + }), + blockedReason: 'plan-review-required', + }); + expect(formatRoomReviewReadyMessage(result)).toBe( + [ + 'Plan review is required before formal review for this high-risk task.', + '- Task: task-1', + '- Plan status: pending', + 'Ask the owner to record a plan and have the reviewer approve it before /review.', + ].join('\n'), + ); + }); + + it('raises a task to high risk and moves it into plan_review_pending', () => { + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ + id: 'task-1', + chat_jid: 'dc:test', + group_folder: group.folder, + owner_service_id: 'codex-main', + reviewer_service_id: 'codex-review', + title: null, + source_ref: 'HEAD', + task_policy: 'autonomous', + risk_level: 'low', + plan_status: 'not_requested', + review_requested_at: null, + status: 'active', + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:00:00.000Z', + }); + vi.mocked(db.getPairedTaskById).mockReturnValue({ + id: 'task-1', + chat_jid: 'dc:test', + group_folder: group.folder, + owner_service_id: 'codex-main', + reviewer_service_id: 'codex-review', + title: null, + source_ref: 'HEAD', + task_policy: 'autonomous', + risk_level: 'high', + plan_status: 'not_requested', + review_requested_at: null, + status: 'plan_review_pending', + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-29T00:00:00.000Z', + }); + + const message = setRoomTaskRiskLevel({ + group, + chatJid: 'dc:test', + roomRoleContext: ownerContext, + riskLevel: 'high', + }); + + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + risk_level: 'high', + plan_status: 'not_requested', + status: 'plan_review_pending', + }), + ); + expect(message).toBe( + [ + 'Task risk updated.', + '- Task: task-1', + '- Risk: high', + '- Status: plan_review_pending', + ].join('\n'), + ); + }); + + it('records plan artifacts for a high-risk task and keeps it pending review', () => { + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ + id: 'task-1', + chat_jid: 'dc:test', + group_folder: group.folder, + owner_service_id: 'codex-main', + reviewer_service_id: 'codex-review', + title: null, + source_ref: 'HEAD', + task_policy: 'autonomous', + risk_level: 'high', + plan_status: 'not_requested', + review_requested_at: null, + status: 'plan_review_pending', + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:00:00.000Z', + }); + vi.mocked(db.getPairedTaskById).mockReturnValue({ + id: 'task-1', + chat_jid: 'dc:test', + group_folder: group.folder, + owner_service_id: 'codex-main', + reviewer_service_id: 'codex-review', + title: null, + source_ref: 'HEAD', + task_policy: 'autonomous', + risk_level: 'high', + plan_status: 'pending', + review_requested_at: null, + status: 'plan_review_pending', + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-29T00:00:00.000Z', + }); + + const message = recordRoomPlan({ + group, + chatJid: 'dc:test', + roomRoleContext: ownerContext, + planBrief: 'ship governance gate', + acceptanceCriteria: 'high risk blocks /review', + riskSummary: 'runtime state drift', + }); + + expect(db.createPairedArtifact).toHaveBeenCalledTimes(3); + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + plan_status: 'pending', + status: 'plan_review_pending', + }), + ); + expect(message).toBe( + [ + 'Plan recorded.', + '- Task: task-1', + '- Plan status: pending', + '- Status: plan_review_pending', + ].join('\n'), + ); + }); + + it('approves a high-risk plan from the reviewer side once plan artifacts exist', () => { + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ + id: 'task-1', + chat_jid: 'dc:test', + group_folder: group.folder, + owner_service_id: 'codex-main', + reviewer_service_id: 'codex-review', + title: null, + source_ref: 'HEAD', + task_policy: 'autonomous', + risk_level: 'high', + plan_status: 'pending', + review_requested_at: null, + status: 'plan_review_pending', + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:00:00.000Z', + }); + vi.mocked(db.listPairedArtifactsForTask).mockReturnValue([ + { + id: 1, + task_id: 'task-1', + execution_id: null, + service_id: 'codex-main', + artifact_type: 'plan_brief', + title: null, + content: 'brief', + file_path: null, + created_at: '2026-03-29T00:00:00.000Z', + }, + { + id: 2, + task_id: 'task-1', + execution_id: null, + service_id: 'codex-main', + artifact_type: 'acceptance_criteria', + title: null, + content: 'criteria', + file_path: null, + created_at: '2026-03-29T00:00:01.000Z', + }, + { + id: 3, + task_id: 'task-1', + execution_id: null, + service_id: 'codex-main', + artifact_type: 'risk_summary', + title: null, + content: 'risk', + file_path: null, + created_at: '2026-03-29T00:00:02.000Z', + }, + ]); + vi.mocked(db.getPairedTaskById).mockReturnValue({ + id: 'task-1', + chat_jid: 'dc:test', + group_folder: group.folder, + owner_service_id: 'codex-main', + reviewer_service_id: 'codex-review', + title: null, + source_ref: 'HEAD', + task_policy: 'autonomous', + risk_level: 'high', + plan_status: 'approved', + review_requested_at: null, + status: 'active', + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-29T00:00:00.000Z', + }); + + const message = approveRoomPlan({ + group, + chatJid: 'dc:test', + roomRoleContext: reviewerContext, + }); + + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + plan_status: 'approved', + status: 'active', + }), + ); + expect(message).toBe( + ['Plan approved.', '- Task: task-1', '- Status: active'].join('\n'), + ); + }); + + it('requests plan changes from the reviewer side and keeps the task pending', () => { + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ + id: 'task-1', + chat_jid: 'dc:test', + group_folder: group.folder, + owner_service_id: 'codex-main', + reviewer_service_id: 'codex-review', + title: null, + source_ref: 'HEAD', + task_policy: 'autonomous', + risk_level: 'high', + plan_status: 'pending', + review_requested_at: null, + status: 'plan_review_pending', + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:00:00.000Z', + }); + vi.mocked(db.getPairedTaskById).mockReturnValue({ + id: 'task-1', + chat_jid: 'dc:test', + group_folder: group.folder, + owner_service_id: 'codex-main', + reviewer_service_id: 'codex-review', + title: null, + source_ref: 'HEAD', + task_policy: 'autonomous', + risk_level: 'high', + plan_status: 'changes_requested', + review_requested_at: null, + status: 'plan_review_pending', + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-29T00:00:00.000Z', + }); + + const message = requestRoomPlanChanges({ + group, + chatJid: 'dc:test', + roomRoleContext: reviewerContext, + note: 'tighten acceptance criteria', + }); + + expect(db.createPairedArtifact).toHaveBeenCalledWith( + expect.objectContaining({ + task_id: 'task-1', + service_id: 'codex-review', + artifact_type: 'comment', + content: 'tighten acceptance criteria', + }), + ); + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + plan_status: 'changes_requested', + status: 'plan_review_pending', + }), + ); + expect(message).toBe( + [ + 'Plan changes requested.', + '- Task: task-1', + '- Plan status: changes_requested', + '- Status: plan_review_pending', + ].join('\n'), + ); + }); }); diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index cd919bc..f1de89b 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -3,22 +3,26 @@ import crypto from 'crypto'; import { SERVICE_ID, normalizeServiceId } from './config.js'; import { + createPairedArtifact, createPairedExecution, createPairedTask, getLatestOpenPairedTaskForChat, getPairedExecutionById, getPairedTaskById, + listPairedArtifactsForTask, updatePairedExecution, updatePairedTask, upsertPairedProject, } from './db.js'; import { logger } from './logger.js'; import { + PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE, markPairedTaskReviewReady, prepareReviewerWorkspaceForExecution, provisionOwnerWorkspaceForPairedTask, } from './paired-workspace-manager.js'; import type { + PairedArtifactType, PairedExecution, PairedTask, PairedWorkspace, @@ -152,19 +156,6 @@ export interface PreparedPairedExecutionContext { blockMessage?: string; } -export type MarkRoomReviewReadyResult = - | { - status: 'ready'; - task: PairedTask; - ownerWorkspace: PairedWorkspace; - reviewerWorkspace: PairedWorkspace; - } - | { - status: 'pending'; - task: PairedTask; - pendingReason: 'owner-workspace-not-ready'; - }; - export function preparePairedExecutionContext(args: { group: RegisteredGroup; chatJid: string; @@ -228,6 +219,85 @@ export function preparePairedExecutionContext(args: { }; } +export type MarkRoomReviewReadyResult = + | { + status: 'ready'; + task: PairedTask; + ownerWorkspace: PairedWorkspace; + reviewerWorkspace: PairedWorkspace; + } + | { + status: 'pending'; + task: PairedTask; + pendingReason: 'owner-workspace-not-ready'; + } + | { + status: 'blocked'; + task: PairedTask; + blockedReason: 'plan-review-required'; + } + | null; + +const PLAN_RECORD_OWNER_ONLY_MESSAGE = + 'Plan recording must be handled by the owner service.'; +const RISK_UPDATE_OWNER_ONLY_MESSAGE = + 'Risk updates must be handled by the owner service.'; +const PLAN_APPROVAL_REVIEWER_ONLY_MESSAGE = + 'Plan approval must be handled by the reviewer service.'; +const PLAN_REQUIRES_HIGH_RISK_MESSAGE = + 'Plan review commands are only required for high-risk tasks.'; +const PLAN_INCOMPLETE_MESSAGE = + 'Plan artifacts are incomplete. Record /plan || || before approval.'; + +function isPlanReviewRequired(task: PairedTask): boolean { + return task.risk_level === 'high' && task.plan_status !== 'approved'; +} + +function getLatestArtifactContent( + taskId: string, + artifactType: PairedArtifactType, +): string | null { + const artifacts = listPairedArtifactsForTask(taskId).filter( + (artifact) => artifact.artifact_type === artifactType, + ); + return artifacts.at(-1)?.content?.trim() || null; +} + +function hasCompletePlanArtifacts(taskId: string): boolean { + return ( + !!getLatestArtifactContent(taskId, 'plan_brief') && + !!getLatestArtifactContent(taskId, 'acceptance_criteria') && + !!getLatestArtifactContent(taskId, 'risk_summary') + ); +} + +function createTaskArtifact(args: { + taskId: string; + serviceId: string; + artifactType: PairedArtifactType; + content: string; +}): void { + createPairedArtifact({ + task_id: args.taskId, + execution_id: null, + service_id: args.serviceId, + artifact_type: args.artifactType, + title: null, + content: args.content, + file_path: null, + created_at: new Date().toISOString(), + }); +} + +function formatPlanReviewRequiredMessage(task: PairedTask): string { + return [ + PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE, + `- Task: ${task.id}`, + `- Plan status: ${task.plan_status}`, + 'Ask the owner to record a plan and have the reviewer approve it before /review.', + ].join('\n'); +} + export function completePairedExecutionContext(args: { executionId: string; status: 'succeeded' | 'failed'; @@ -244,7 +314,7 @@ export function markRoomReviewReady(args: { group: RegisteredGroup; chatJid: string; roomRoleContext?: RoomRoleContext; -}): MarkRoomReviewReadyResult | null { +}): MarkRoomReviewReadyResult { const { group, chatJid, roomRoleContext } = args; if (!roomRoleContext || !group.workDir) { return null; @@ -255,6 +325,14 @@ export function markRoomReviewReady(args: { return null; } + if (isPlanReviewRequired(task)) { + return { + status: 'blocked', + task, + blockedReason: 'plan-review-required', + }; + } + const isOwnerService = normalizeServiceId(SERVICE_ID) === task.owner_service_id; if (isOwnerService) { @@ -262,11 +340,10 @@ export function markRoomReviewReady(args: { } const reviewReady = markPairedTaskReviewReady(task.id); - const latestTask = getPairedTaskById(task.id) ?? task; if (!reviewReady) { return { status: 'pending', - task: latestTask, + task: getPairedTaskById(task.id) ?? task, pendingReason: 'owner-workspace-not-ready', }; } @@ -274,19 +351,23 @@ export function markRoomReviewReady(args: { const { ownerWorkspace, reviewerWorkspace } = reviewReady; return { status: 'ready', - task: latestTask, + task: getPairedTaskById(task.id) ?? task, ownerWorkspace, reviewerWorkspace, }; } export function formatRoomReviewReadyMessage( - result: MarkRoomReviewReadyResult | null, + result: MarkRoomReviewReadyResult, ): string | null { if (!result) { return null; } + if (result.status === 'blocked') { + return formatPlanReviewRequiredMessage(result.task); + } + if (result.status === 'pending') { return [ 'Review request recorded, but the owner workspace is not ready yet.', @@ -302,3 +383,196 @@ export function formatRoomReviewReadyMessage( `- Reviewer snapshot: ${result.reviewerWorkspace.workspace_dir}`, ].join('\n'); } + +export function setRoomTaskRiskLevel(args: { + group: RegisteredGroup; + chatJid: string; + roomRoleContext?: RoomRoleContext; + riskLevel: 'low' | 'high'; +}): string | null { + const { group, chatJid, roomRoleContext, riskLevel } = args; + if (!roomRoleContext || !group.workDir) { + return null; + } + if (roomRoleContext.role !== 'owner') { + return RISK_UPDATE_OWNER_ONLY_MESSAGE; + } + + const task = ensureActiveTask(group, chatJid, roomRoleContext); + if (!task) { + return null; + } + + const now = new Date().toISOString(); + if (riskLevel === 'high') { + updatePairedTask(task.id, { + risk_level: 'high', + plan_status: hasCompletePlanArtifacts(task.id) ? 'pending' : 'not_requested', + status: 'plan_review_pending', + updated_at: now, + }); + } else { + updatePairedTask(task.id, { + risk_level: 'low', + status: task.status === 'plan_review_pending' ? 'active' : task.status, + updated_at: now, + }); + } + + const latestTask = getPairedTaskById(task.id) ?? task; + return [ + 'Task risk updated.', + `- Task: ${latestTask.id}`, + `- Risk: ${latestTask.risk_level}`, + `- Status: ${latestTask.status}`, + ].join('\n'); +} + +export function recordRoomPlan(args: { + group: RegisteredGroup; + chatJid: string; + roomRoleContext?: RoomRoleContext; + planBrief: string; + acceptanceCriteria: string; + riskSummary: string; +}): string | null { + const { + group, + chatJid, + roomRoleContext, + planBrief, + acceptanceCriteria, + riskSummary, + } = args; + if (!roomRoleContext || !group.workDir) { + return null; + } + if (roomRoleContext.role !== 'owner') { + return PLAN_RECORD_OWNER_ONLY_MESSAGE; + } + + const task = ensureActiveTask(group, chatJid, roomRoleContext); + if (!task) { + return null; + } + if (task.risk_level !== 'high') { + return PLAN_REQUIRES_HIGH_RISK_MESSAGE; + } + + createTaskArtifact({ + taskId: task.id, + serviceId: roomRoleContext.serviceId, + artifactType: 'plan_brief', + content: planBrief, + }); + createTaskArtifact({ + taskId: task.id, + serviceId: roomRoleContext.serviceId, + artifactType: 'acceptance_criteria', + content: acceptanceCriteria, + }); + createTaskArtifact({ + taskId: task.id, + serviceId: roomRoleContext.serviceId, + artifactType: 'risk_summary', + content: riskSummary, + }); + + updatePairedTask(task.id, { + plan_status: 'pending', + status: 'plan_review_pending', + updated_at: new Date().toISOString(), + }); + + const latestTask = getPairedTaskById(task.id) ?? task; + return [ + 'Plan recorded.', + `- Task: ${latestTask.id}`, + `- Plan status: ${latestTask.plan_status}`, + `- Status: ${latestTask.status}`, + ].join('\n'); +} + +export function approveRoomPlan(args: { + group: RegisteredGroup; + chatJid: string; + roomRoleContext?: RoomRoleContext; +}): string | null { + const { group, chatJid, roomRoleContext } = args; + if (!roomRoleContext || !group.workDir) { + return null; + } + if (roomRoleContext.role !== 'reviewer') { + return PLAN_APPROVAL_REVIEWER_ONLY_MESSAGE; + } + + const task = ensureActiveTask(group, chatJid, roomRoleContext); + if (!task) { + return null; + } + if (task.risk_level !== 'high') { + return PLAN_REQUIRES_HIGH_RISK_MESSAGE; + } + if (!hasCompletePlanArtifacts(task.id)) { + return PLAN_INCOMPLETE_MESSAGE; + } + + updatePairedTask(task.id, { + plan_status: 'approved', + status: 'active', + updated_at: new Date().toISOString(), + }); + + const latestTask = getPairedTaskById(task.id) ?? task; + return [ + 'Plan approved.', + `- Task: ${latestTask.id}`, + `- Status: ${latestTask.status}`, + ].join('\n'); +} + +export function requestRoomPlanChanges(args: { + group: RegisteredGroup; + chatJid: string; + roomRoleContext?: RoomRoleContext; + note?: string; +}): string | null { + const { group, chatJid, roomRoleContext, note } = args; + if (!roomRoleContext || !group.workDir) { + return null; + } + if (roomRoleContext.role !== 'reviewer') { + return PLAN_APPROVAL_REVIEWER_ONLY_MESSAGE; + } + + const task = ensureActiveTask(group, chatJid, roomRoleContext); + if (!task) { + return null; + } + if (task.risk_level !== 'high') { + return PLAN_REQUIRES_HIGH_RISK_MESSAGE; + } + + if (note?.trim()) { + createTaskArtifact({ + taskId: task.id, + serviceId: roomRoleContext.serviceId, + artifactType: 'comment', + content: note.trim(), + }); + } + + updatePairedTask(task.id, { + plan_status: 'changes_requested', + status: 'plan_review_pending', + updated_at: new Date().toISOString(), + }); + + const latestTask = getPairedTaskById(task.id) ?? task; + return [ + 'Plan changes requested.', + `- Task: ${latestTask.id}`, + `- Plan status: ${latestTask.plan_status}`, + `- Status: ${latestTask.status}`, + ].join('\n'); +} diff --git a/src/paired-review-command.test.ts b/src/paired-review-command.test.ts index 2d6053a..8645a3f 100644 --- a/src/paired-review-command.test.ts +++ b/src/paired-review-command.test.ts @@ -199,4 +199,62 @@ describe('paired /review command path', () => { expect(task?.review_requested_at).toBeTruthy(); expect(fs.existsSync(reviewerLocalOwnerDir)).toBe(false); }); + + it('blocks /review when a high-risk task plan is not approved yet', async () => { + const canonicalDir = path.join(tempRoot, 'canonical'); + fs.mkdirSync(canonicalDir, { recursive: true }); + runGit(['init'], canonicalDir); + runGit(['config', 'user.email', 'test@example.com'], canonicalDir); + runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir); + fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'original\n'); + runGit(['add', 'README.md'], canonicalDir); + runGit(['commit', '-m', 'initial'], canonicalDir); + + const group: RegisteredGroup = { + name: 'Paired Room', + folder: 'paired-room', + trigger: '@codex', + added_at: '2026-03-28T00:00:00.000Z', + agentType: 'codex', + workDir: canonicalDir, + }; + + process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data-owner'); + process.env.SERVICE_ID = 'codex-main'; + vi.resetModules(); + const { db, executionContext } = await loadModules(); + db.initDatabase(); + + const task = executionContext.preparePairedExecutionContext({ + group, + chatJid: 'dc:test', + runId: 'run-owner', + roomRoleContext: ownerContext, + })?.task; + + expect(task).toBeTruthy(); + db.updatePairedTask(task!.id, { + risk_level: 'high', + plan_status: 'pending', + status: 'plan_review_pending', + updated_at: '2026-03-29T00:00:00.000Z', + }); + + const result = executionContext.markRoomReviewReady({ + group, + chatJid: 'dc:test', + roomRoleContext: ownerContext, + }); + + expect(result).toEqual({ + status: 'blocked', + task: expect.objectContaining({ + id: task!.id, + risk_level: 'high', + plan_status: 'pending', + status: 'plan_review_pending', + }), + blockedReason: 'plan-review-required', + }); + }); }); diff --git a/src/paired-workspace-manager.test.ts b/src/paired-workspace-manager.test.ts index 0720023..7d5839d 100644 --- a/src/paired-workspace-manager.test.ts +++ b/src/paired-workspace-manager.test.ts @@ -833,4 +833,54 @@ describe('paired workspace manager', () => { db.getPairedTaskById('paired-task-6')?.review_requested_at, ).toBeTruthy(); }); + it('blocks reviewer execution for a high-risk task until the plan is approved', async () => { + const { db, manager } = await loadModules(); + db._initTestDatabase(); + + const canonicalDir = path.join(tempRoot, 'canonical'); + fs.mkdirSync(canonicalDir, { recursive: true }); + runGit(['init'], canonicalDir); + runGit(['config', 'user.email', 'test@example.com'], canonicalDir); + runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir); + fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'base\n'); + runGit(['add', 'README.md'], canonicalDir); + runGit(['commit', '-m', 'initial'], canonicalDir); + + const now = '2026-03-28T00:00:00.000Z'; + db.upsertPairedProject({ + chat_jid: 'dc:test', + group_folder: 'paired-room', + canonical_work_dir: canonicalDir, + workspace_topology: 'shadow-snapshot', + created_at: now, + updated_at: now, + }); + db.createPairedTask({ + id: 'paired-task-plan-gate', + chat_jid: 'dc:test', + group_folder: 'paired-room', + owner_service_id: 'codex-main', + reviewer_service_id: 'codex-review', + title: null, + source_ref: 'HEAD', + task_policy: 'autonomous', + risk_level: 'high', + plan_status: 'pending', + review_requested_at: null, + status: 'plan_review_pending', + created_at: now, + updated_at: now, + }); + + const result = manager.prepareReviewerWorkspaceForExecution( + db.getPairedTaskById('paired-task-plan-gate')!, + ); + + expect(result).toEqual({ + workspace: null, + autoRefreshed: false, + blockMessage: + 'Plan review is required before formal review for this high-risk task.', + }); + }); }); diff --git a/src/paired-workspace-manager.ts b/src/paired-workspace-manager.ts index 91c330b..110db50 100644 --- a/src/paired-workspace-manager.ts +++ b/src/paired-workspace-manager.ts @@ -18,6 +18,8 @@ const REVIEWER_SNAPSHOT_STALE_BLOCK_MESSAGE = 'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.'; const REVIEWER_SNAPSHOT_NOT_READY_BLOCK_MESSAGE = 'Review snapshot is not ready yet. Ask the owner to run /review (or /review-ready) after preparing changes.'; +export const PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE = + 'Plan review is required before formal review for this high-risk task.'; const REVIEWER_SNAPSHOT_DENY_SEGMENTS = new Set([ '.git', @@ -471,6 +473,14 @@ export interface PreparedReviewerWorkspace { export function prepareReviewerWorkspaceForExecution( task: PairedTask, ): PreparedReviewerWorkspace { + if (task.risk_level === 'high' && task.plan_status !== 'approved') { + return { + workspace: null, + autoRefreshed: false, + blockMessage: PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE, + }; + } + const ownerWorkspace = getPairedWorkspace(task.id, 'owner') ?? null; if (!ownerWorkspace) { return { diff --git a/src/session-commands.test.ts b/src/session-commands.test.ts index f320581..cca841b 100644 --- a/src/session-commands.test.ts +++ b/src/session-commands.test.ts @@ -31,6 +31,28 @@ describe('extractSessionCommand', () => { expect(extractSessionCommand('/review-ready', trigger)).toBe('/review'); }); + it('detects /risk with an argument payload', () => { + expect(extractSessionCommand('/risk high', trigger)).toBe('/risk'); + }); + + it('detects /plan with structured payload', () => { + expect( + extractSessionCommand('/plan brief || criteria || risk', trigger), + ).toBe('/plan'); + }); + + it('detects /approve-plan', () => { + expect(extractSessionCommand('/approve-plan', trigger)).toBe( + '/approve-plan', + ); + }); + + it('detects /request-plan-changes with an optional note', () => { + expect( + extractSessionCommand('/request-plan-changes tighten scope', trigger), + ).toBe('/request-plan-changes'); + }); + it('rejects /compact with extra text', () => { expect(extractSessionCommand('/compact now please', trigger)).toBeNull(); }); @@ -110,6 +132,20 @@ describe('isSessionCommandControlMessage', () => { ).toBe(true); }); + it('matches plan-gate control output', () => { + expect( + isSessionCommandControlMessage( + 'Plan review is required before formal review for this high-risk task.\n- Task: task-1', + ), + ).toBe(true); + }); + + it('matches plan artifact control output', () => { + expect(isSessionCommandControlMessage('Plan recorded.\n- Task: task-1')).toBe( + true, + ); + }); + it('does not match regular bot conversation', () => { expect( isSessionCommandControlMessage( @@ -148,6 +184,10 @@ function makeDeps( isAdminSender: vi.fn().mockReturnValue(false), canSenderInteract: vi.fn().mockReturnValue(true), markReviewReady: vi.fn().mockResolvedValue('Review snapshot updated.'), + setTaskRiskLevel: vi.fn().mockResolvedValue('Task risk updated.'), + recordPlan: vi.fn().mockResolvedValue('Plan recorded.'), + approvePlan: vi.fn().mockResolvedValue('Plan approved.'), + requestPlanChanges: vi.fn().mockResolvedValue('Plan changes requested.'), ...overrides, }; } @@ -289,6 +329,124 @@ describe('handleSessionCommand', () => { ); }); + it('handles authorized /risk without invoking the agent', async () => { + const deps = makeDeps({ + setTaskRiskLevel: vi + .fn() + .mockResolvedValue('Task risk updated.\n- Task: paired-task-1'), + }); + + const result = await handleSessionCommand({ + missedMessages: [makeMsg('/risk high')], + isMainGroup: true, + groupName: 'test', + triggerPattern: trigger, + timezone: 'UTC', + deps, + }); + + expect(result).toEqual({ handled: true, success: true }); + expect(deps.setTaskRiskLevel).toHaveBeenCalledWith('high'); + expect(deps.runAgent).not.toHaveBeenCalled(); + expect(deps.sendMessage).toHaveBeenCalledWith( + 'Task risk updated.\n- Task: paired-task-1', + ); + }); + + it('handles authorized /plan without invoking the agent', async () => { + const deps = makeDeps({ + recordPlan: vi + .fn() + .mockResolvedValue('Plan recorded.\n- Task: paired-task-1'), + }); + + const result = await handleSessionCommand({ + missedMessages: [ + makeMsg('/plan brief || acceptance criteria || rollout risk'), + ], + isMainGroup: true, + groupName: 'test', + triggerPattern: trigger, + timezone: 'UTC', + deps, + }); + + expect(result).toEqual({ handled: true, success: true }); + expect(deps.recordPlan).toHaveBeenCalledWith({ + planBrief: 'brief', + acceptanceCriteria: 'acceptance criteria', + riskSummary: 'rollout risk', + }); + expect(deps.sendMessage).toHaveBeenCalledWith( + 'Plan recorded.\n- Task: paired-task-1', + ); + }); + + it('sends /plan usage when structured payload is incomplete', async () => { + const deps = makeDeps(); + + const result = await handleSessionCommand({ + missedMessages: [makeMsg('/plan only brief')], + isMainGroup: true, + groupName: 'test', + triggerPattern: trigger, + timezone: 'UTC', + deps, + }); + + expect(result).toEqual({ handled: true, success: true }); + expect(deps.recordPlan).not.toHaveBeenCalled(); + expect(deps.sendMessage).toHaveBeenCalledWith( + 'Usage: /plan || || ', + ); + }); + + it('handles /approve-plan without invoking the agent', async () => { + const deps = makeDeps({ + approvePlan: vi + .fn() + .mockResolvedValue('Plan approved.\n- Task: paired-task-1'), + }); + + const result = await handleSessionCommand({ + missedMessages: [makeMsg('/approve-plan')], + isMainGroup: true, + groupName: 'test', + triggerPattern: trigger, + timezone: 'UTC', + deps, + }); + + expect(result).toEqual({ handled: true, success: true }); + expect(deps.approvePlan).toHaveBeenCalledTimes(1); + expect(deps.sendMessage).toHaveBeenCalledWith( + 'Plan approved.\n- Task: paired-task-1', + ); + }); + + it('handles /request-plan-changes without invoking the agent', async () => { + const deps = makeDeps({ + requestPlanChanges: vi + .fn() + .mockResolvedValue('Plan changes requested.\n- Task: paired-task-1'), + }); + + const result = await handleSessionCommand({ + missedMessages: [makeMsg('/request-plan-changes tighten scope')], + isMainGroup: true, + groupName: 'test', + triggerPattern: trigger, + timezone: 'UTC', + deps, + }); + + expect(result).toEqual({ handled: true, success: true }); + expect(deps.requestPlanChanges).toHaveBeenCalledWith('tighten scope'); + expect(deps.sendMessage).toHaveBeenCalledWith( + 'Plan changes requested.\n- Task: paired-task-1', + ); + }); + it('sends denial to interactable sender in non-main group', async () => { const deps = makeDeps(); const result = await handleSessionCommand({ diff --git a/src/session-commands.ts b/src/session-commands.ts index 6973dc6..2c01ab7 100644 --- a/src/session-commands.ts +++ b/src/session-commands.ts @@ -12,9 +12,26 @@ const SESSION_COMMAND_CONTROL_PATTERNS = [ /^Conversation compacted\.$/, /^Review snapshot updated\.(?:\n|$)/, /^Review request recorded, but the owner workspace is not ready yet\.(?:\n|$)/, + /^Plan review is required before formal review for this high-risk task\.(?:\n|$)/, + /^Task risk updated\.(?:\n|$)/, + /^Plan recorded\.(?:\n|$)/, + /^Plan approved\.(?:\n|$)/, + /^Plan changes requested\.(?:\n|$)/, + /^Risk updates must be handled by the owner service\.$/, + /^Plan recording must be handled by the owner service\.$/, + /^Plan approval must be handled by the reviewer service\.$/, + /^Plan review commands are only required for high-risk tasks\.$/, + /^Plan artifacts are incomplete\.(?:\n|$)/, /^Review is unavailable for this room\./, ]; +function normalizeSessionCommandText( + content: string, + triggerPattern: RegExp, +): string { + return content.trim().replace(triggerPattern, '').trim(); +} + /** * Extract a session slash command from a message, stripping the trigger prefix if present. * Returns the slash command (e.g., '/compact') or null if not a session command. @@ -23,11 +40,16 @@ export function extractSessionCommand( content: string, triggerPattern: RegExp, ): string | null { - let text = content.trim(); - text = text.replace(triggerPattern, '').trim(); + const text = normalizeSessionCommandText(content, triggerPattern); if (text === '/compact') return '/compact'; if (text === '/clear') return '/clear'; if (text === '/review' || text === '/review-ready') return '/review'; + if (/^\/risk(?:\s|$)/.test(text)) return '/risk'; + if (/^\/plan(?:\s|$)/.test(text)) return '/plan'; + if (text === '/approve-plan') return '/approve-plan'; + if (/^\/request-plan-changes(?:\s|$)/.test(text)) { + return '/request-plan-changes'; + } return null; } @@ -73,6 +95,14 @@ export interface SessionCommandDeps { /** Whether the denied sender would normally be allowed to interact (for denial messages). */ canSenderInteract: (msg: NewMessage) => boolean; markReviewReady: () => Promise; + setTaskRiskLevel: (riskLevel: 'low' | 'high') => Promise; + recordPlan: (plan: { + planBrief: string; + acceptanceCriteria: string; + riskSummary: string; + }) => Promise; + approvePlan: () => Promise; + requestPlanChanges: (note?: string) => Promise; } function resultToText(result: string | object | null | undefined): string { @@ -120,6 +150,9 @@ export async function handleSessionCommand(opts: { const command = cmdMsg ? extractSessionCommand(cmdMsg.content, triggerPattern) : null; + const normalizedCommandText = cmdMsg + ? normalizeSessionCommandText(cmdMsg.content, triggerPattern) + : ''; if (!command || !cmdMsg) return { handled: false }; @@ -164,6 +197,60 @@ export async function handleSessionCommand(opts: { return { handled: true, success: true }; } + if (command === '/risk') { + const riskArg = normalizedCommandText.slice('/risk'.length).trim().toLowerCase(); + const riskLevel = + riskArg === 'high' ? 'high' : riskArg === 'low' ? 'low' : null; + deps.advanceCursor(cmdMsg.timestamp); + await deps.sendMessage( + riskLevel + ? (await deps.setTaskRiskLevel(riskLevel)) ?? + 'Risk is unavailable for this room.' + : 'Usage: /risk ', + ); + return { handled: true, success: true }; + } + + if (command === '/plan') { + const payload = normalizedCommandText.slice('/plan'.length).trim(); + const parts = payload.split('||').map((part) => part.trim()); + deps.advanceCursor(cmdMsg.timestamp); + if (parts.length !== 3 || parts.some((part) => !part)) { + await deps.sendMessage( + 'Usage: /plan || || ', + ); + return { handled: true, success: true }; + } + await deps.sendMessage( + (await deps.recordPlan({ + planBrief: parts[0], + acceptanceCriteria: parts[1], + riskSummary: parts[2], + })) ?? 'Plan recording is unavailable for this room.', + ); + return { handled: true, success: true }; + } + + if (command === '/approve-plan') { + deps.advanceCursor(cmdMsg.timestamp); + await deps.sendMessage( + (await deps.approvePlan()) ?? 'Plan approval is unavailable for this room.', + ); + return { handled: true, success: true }; + } + + if (command === '/request-plan-changes') { + const note = normalizedCommandText + .slice('/request-plan-changes'.length) + .trim(); + deps.advanceCursor(cmdMsg.timestamp); + await deps.sendMessage( + (await deps.requestPlanChanges(note || undefined)) ?? + 'Plan change requests are unavailable for this room.', + ); + return { handled: true, success: true }; + } + const cmdIndex = missedMessages.indexOf(cmdMsg); const preCompactMsgs = missedMessages.slice(0, cmdIndex);