feat: add high-risk paired plan gate

This commit is contained in:
Eyejoker
2026-03-29 02:12:28 +09:00
parent c76ed1d10a
commit b536c90716
9 changed files with 1288 additions and 20 deletions

View File

@@ -38,6 +38,10 @@ vi.mock('./paired-execution-context.js', () => ({
completePairedExecutionContext: vi.fn(), completePairedExecutionContext: vi.fn(),
markRoomReviewReady: vi.fn(() => null), markRoomReviewReady: vi.fn(() => null),
formatRoomReviewReadyMessage: 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', () => { vi.mock('./db.js', () => {
@@ -312,6 +316,249 @@ describe('createMessageRuntime', () => {
expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, pendingMessage); 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<string, string> = {};
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<typeof import('./session-commands.js')>(
'./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<string, string> = {};
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<typeof import('./session-commands.js')>(
'./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<string, string> = {};
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<typeof import('./session-commands.js')>(
'./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 () => { it('ignores generic failure bot messages in paired rooms', async () => {
const chatJid = 'group@test'; const chatJid = 'group@test';
const group = makeGroup('codex'); const group = makeGroup('codex');

View File

@@ -42,8 +42,12 @@ import { runAgentForGroup } from './message-agent-executor.js';
import { MessageTurnController } from './message-turn-controller.js'; import { MessageTurnController } from './message-turn-controller.js';
import { createSuppressToken } from './output-suppression.js'; import { createSuppressToken } from './output-suppression.js';
import { import {
approveRoomPlan,
formatRoomReviewReadyMessage, formatRoomReviewReadyMessage,
markRoomReviewReady, markRoomReviewReady,
recordRoomPlan,
requestRoomPlanChanges,
setRoomTaskRiskLevel,
} from './paired-execution-context.js'; } from './paired-execution-context.js';
import { buildRoomRoleContext } from './room-role-context.js'; import { buildRoomRoleContext } from './room-role-context.js';
import { import {
@@ -565,6 +569,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist()))) 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 () => { markReviewReady: async () => {
const lease = getEffectiveChannelLease(chatJid); const lease = getEffectiveChannelLease(chatJid);
const roomRoleContext = buildRoomRoleContext( const roomRoleContext = buildRoomRoleContext(
@@ -578,6 +585,57 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
}); });
return formatRoomReviewReadyMessage(result); 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; if (cmdResult.handled) return cmdResult.success;

View File

@@ -1,18 +1,22 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./db.js', () => ({ vi.mock('./db.js', () => ({
createPairedArtifact: vi.fn(),
createPairedExecution: vi.fn(), createPairedExecution: vi.fn(),
createPairedTask: vi.fn(), createPairedTask: vi.fn(),
getLatestOpenPairedTaskForChat: vi.fn(), getLatestOpenPairedTaskForChat: vi.fn(),
getPairedExecutionById: vi.fn(), getPairedExecutionById: vi.fn(),
getPairedTaskById: vi.fn(), getPairedTaskById: vi.fn(),
getPairedWorkspace: vi.fn(), getPairedWorkspace: vi.fn(),
listPairedArtifactsForTask: vi.fn(),
updatePairedExecution: vi.fn(), updatePairedExecution: vi.fn(),
updatePairedTask: vi.fn(), updatePairedTask: vi.fn(),
upsertPairedProject: vi.fn(), upsertPairedProject: vi.fn(),
})); }));
vi.mock('./paired-workspace-manager.js', () => ({ 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(), markPairedTaskReviewReady: vi.fn(),
prepareReviewerWorkspaceForExecution: vi.fn(), prepareReviewerWorkspaceForExecution: vi.fn(),
provisionOwnerWorkspaceForPairedTask: vi.fn(), provisionOwnerWorkspaceForPairedTask: vi.fn(),
@@ -29,9 +33,13 @@ vi.mock('./logger.js', () => ({
import * as db from './db.js'; import * as db from './db.js';
import { import {
approveRoomPlan,
formatRoomReviewReadyMessage, formatRoomReviewReadyMessage,
markRoomReviewReady, markRoomReviewReady,
preparePairedExecutionContext, preparePairedExecutionContext,
recordRoomPlan,
requestRoomPlanChanges,
setRoomTaskRiskLevel,
} from './paired-execution-context.js'; } from './paired-execution-context.js';
import * as pairedWorkspaceManager from './paired-workspace-manager.js'; import * as pairedWorkspaceManager from './paired-workspace-manager.js';
import type { RegisteredGroup, RoomRoleContext } from './types.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.getPairedExecutionById).mockReturnValue(undefined);
vi.mocked(db.getPairedTaskById).mockReturnValue(undefined); vi.mocked(db.getPairedTaskById).mockReturnValue(undefined);
vi.mocked(db.getPairedWorkspace).mockReturnValue(undefined); vi.mocked(db.getPairedWorkspace).mockReturnValue(undefined);
vi.mocked(db.listPairedArtifactsForTask).mockReturnValue([]);
vi.mocked( vi.mocked(
pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask, pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask,
).mockReturnValue({ ).mockReturnValue({
@@ -408,4 +417,321 @@ describe('paired execution context', () => {
].join('\n'), ].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'),
);
});
}); });

View File

@@ -3,22 +3,26 @@ import crypto from 'crypto';
import { SERVICE_ID, normalizeServiceId } from './config.js'; import { SERVICE_ID, normalizeServiceId } from './config.js';
import { import {
createPairedArtifact,
createPairedExecution, createPairedExecution,
createPairedTask, createPairedTask,
getLatestOpenPairedTaskForChat, getLatestOpenPairedTaskForChat,
getPairedExecutionById, getPairedExecutionById,
getPairedTaskById, getPairedTaskById,
listPairedArtifactsForTask,
updatePairedExecution, updatePairedExecution,
updatePairedTask, updatePairedTask,
upsertPairedProject, upsertPairedProject,
} from './db.js'; } from './db.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { import {
PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE,
markPairedTaskReviewReady, markPairedTaskReviewReady,
prepareReviewerWorkspaceForExecution, prepareReviewerWorkspaceForExecution,
provisionOwnerWorkspaceForPairedTask, provisionOwnerWorkspaceForPairedTask,
} from './paired-workspace-manager.js'; } from './paired-workspace-manager.js';
import type { import type {
PairedArtifactType,
PairedExecution, PairedExecution,
PairedTask, PairedTask,
PairedWorkspace, PairedWorkspace,
@@ -152,19 +156,6 @@ export interface PreparedPairedExecutionContext {
blockMessage?: string; 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: { export function preparePairedExecutionContext(args: {
group: RegisteredGroup; group: RegisteredGroup;
chatJid: string; 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 <plan brief> || <acceptance criteria> || <risk summary> 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: { export function completePairedExecutionContext(args: {
executionId: string; executionId: string;
status: 'succeeded' | 'failed'; status: 'succeeded' | 'failed';
@@ -244,7 +314,7 @@ export function markRoomReviewReady(args: {
group: RegisteredGroup; group: RegisteredGroup;
chatJid: string; chatJid: string;
roomRoleContext?: RoomRoleContext; roomRoleContext?: RoomRoleContext;
}): MarkRoomReviewReadyResult | null { }): MarkRoomReviewReadyResult {
const { group, chatJid, roomRoleContext } = args; const { group, chatJid, roomRoleContext } = args;
if (!roomRoleContext || !group.workDir) { if (!roomRoleContext || !group.workDir) {
return null; return null;
@@ -255,6 +325,14 @@ export function markRoomReviewReady(args: {
return null; return null;
} }
if (isPlanReviewRequired(task)) {
return {
status: 'blocked',
task,
blockedReason: 'plan-review-required',
};
}
const isOwnerService = const isOwnerService =
normalizeServiceId(SERVICE_ID) === task.owner_service_id; normalizeServiceId(SERVICE_ID) === task.owner_service_id;
if (isOwnerService) { if (isOwnerService) {
@@ -262,11 +340,10 @@ export function markRoomReviewReady(args: {
} }
const reviewReady = markPairedTaskReviewReady(task.id); const reviewReady = markPairedTaskReviewReady(task.id);
const latestTask = getPairedTaskById(task.id) ?? task;
if (!reviewReady) { if (!reviewReady) {
return { return {
status: 'pending', status: 'pending',
task: latestTask, task: getPairedTaskById(task.id) ?? task,
pendingReason: 'owner-workspace-not-ready', pendingReason: 'owner-workspace-not-ready',
}; };
} }
@@ -274,19 +351,23 @@ export function markRoomReviewReady(args: {
const { ownerWorkspace, reviewerWorkspace } = reviewReady; const { ownerWorkspace, reviewerWorkspace } = reviewReady;
return { return {
status: 'ready', status: 'ready',
task: latestTask, task: getPairedTaskById(task.id) ?? task,
ownerWorkspace, ownerWorkspace,
reviewerWorkspace, reviewerWorkspace,
}; };
} }
export function formatRoomReviewReadyMessage( export function formatRoomReviewReadyMessage(
result: MarkRoomReviewReadyResult | null, result: MarkRoomReviewReadyResult,
): string | null { ): string | null {
if (!result) { if (!result) {
return null; return null;
} }
if (result.status === 'blocked') {
return formatPlanReviewRequiredMessage(result.task);
}
if (result.status === 'pending') { if (result.status === 'pending') {
return [ return [
'Review request recorded, but the owner workspace is not ready yet.', 'Review request recorded, but the owner workspace is not ready yet.',
@@ -302,3 +383,196 @@ export function formatRoomReviewReadyMessage(
`- Reviewer snapshot: ${result.reviewerWorkspace.workspace_dir}`, `- Reviewer snapshot: ${result.reviewerWorkspace.workspace_dir}`,
].join('\n'); ].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');
}

View File

@@ -199,4 +199,62 @@ describe('paired /review command path', () => {
expect(task?.review_requested_at).toBeTruthy(); expect(task?.review_requested_at).toBeTruthy();
expect(fs.existsSync(reviewerLocalOwnerDir)).toBe(false); 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',
});
});
}); });

View File

@@ -833,4 +833,54 @@ describe('paired workspace manager', () => {
db.getPairedTaskById('paired-task-6')?.review_requested_at, db.getPairedTaskById('paired-task-6')?.review_requested_at,
).toBeTruthy(); ).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.',
});
});
}); });

View File

@@ -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.'; '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 = 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.'; '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([ const REVIEWER_SNAPSHOT_DENY_SEGMENTS = new Set([
'.git', '.git',
@@ -471,6 +473,14 @@ export interface PreparedReviewerWorkspace {
export function prepareReviewerWorkspaceForExecution( export function prepareReviewerWorkspaceForExecution(
task: PairedTask, task: PairedTask,
): PreparedReviewerWorkspace { ): 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; const ownerWorkspace = getPairedWorkspace(task.id, 'owner') ?? null;
if (!ownerWorkspace) { if (!ownerWorkspace) {
return { return {

View File

@@ -31,6 +31,28 @@ describe('extractSessionCommand', () => {
expect(extractSessionCommand('/review-ready', trigger)).toBe('/review'); 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', () => { it('rejects /compact with extra text', () => {
expect(extractSessionCommand('/compact now please', trigger)).toBeNull(); expect(extractSessionCommand('/compact now please', trigger)).toBeNull();
}); });
@@ -110,6 +132,20 @@ describe('isSessionCommandControlMessage', () => {
).toBe(true); ).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', () => { it('does not match regular bot conversation', () => {
expect( expect(
isSessionCommandControlMessage( isSessionCommandControlMessage(
@@ -148,6 +184,10 @@ function makeDeps(
isAdminSender: vi.fn().mockReturnValue(false), isAdminSender: vi.fn().mockReturnValue(false),
canSenderInteract: vi.fn().mockReturnValue(true), canSenderInteract: vi.fn().mockReturnValue(true),
markReviewReady: vi.fn().mockResolvedValue('Review snapshot updated.'), 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, ...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 <plan brief> || <acceptance criteria> || <risk summary>',
);
});
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 () => { it('sends denial to interactable sender in non-main group', async () => {
const deps = makeDeps(); const deps = makeDeps();
const result = await handleSessionCommand({ const result = await handleSessionCommand({

View File

@@ -12,9 +12,26 @@ const SESSION_COMMAND_CONTROL_PATTERNS = [
/^Conversation compacted\.$/, /^Conversation compacted\.$/,
/^Review snapshot updated\.(?:\n|$)/, /^Review snapshot updated\.(?:\n|$)/,
/^Review request recorded, but the owner workspace is not ready yet\.(?:\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\./, /^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. * 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. * Returns the slash command (e.g., '/compact') or null if not a session command.
@@ -23,11 +40,16 @@ export function extractSessionCommand(
content: string, content: string,
triggerPattern: RegExp, triggerPattern: RegExp,
): string | null { ): string | null {
let text = content.trim(); const text = normalizeSessionCommandText(content, triggerPattern);
text = text.replace(triggerPattern, '').trim();
if (text === '/compact') return '/compact'; if (text === '/compact') return '/compact';
if (text === '/clear') return '/clear'; if (text === '/clear') return '/clear';
if (text === '/review' || text === '/review-ready') return '/review'; 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; return null;
} }
@@ -73,6 +95,14 @@ export interface SessionCommandDeps {
/** Whether the denied sender would normally be allowed to interact (for denial messages). */ /** Whether the denied sender would normally be allowed to interact (for denial messages). */
canSenderInteract: (msg: NewMessage) => boolean; canSenderInteract: (msg: NewMessage) => boolean;
markReviewReady: () => Promise<string | null>; markReviewReady: () => Promise<string | null>;
setTaskRiskLevel: (riskLevel: 'low' | 'high') => Promise<string | null>;
recordPlan: (plan: {
planBrief: string;
acceptanceCriteria: string;
riskSummary: string;
}) => Promise<string | null>;
approvePlan: () => Promise<string | null>;
requestPlanChanges: (note?: string) => Promise<string | null>;
} }
function resultToText(result: string | object | null | undefined): string { function resultToText(result: string | object | null | undefined): string {
@@ -120,6 +150,9 @@ export async function handleSessionCommand(opts: {
const command = cmdMsg const command = cmdMsg
? extractSessionCommand(cmdMsg.content, triggerPattern) ? extractSessionCommand(cmdMsg.content, triggerPattern)
: null; : null;
const normalizedCommandText = cmdMsg
? normalizeSessionCommandText(cmdMsg.content, triggerPattern)
: '';
if (!command || !cmdMsg) return { handled: false }; if (!command || !cmdMsg) return { handled: false };
@@ -164,6 +197,60 @@ export async function handleSessionCommand(opts: {
return { handled: true, success: true }; 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 <low|high>',
);
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 <plan brief> || <acceptance criteria> || <risk summary>',
);
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 cmdIndex = missedMessages.indexOf(cmdMsg);
const preCompactMsgs = missedMessages.slice(0, cmdIndex); const preCompactMsgs = missedMessages.slice(0, cmdIndex);