diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-reply-satisfaction.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-reply-satisfaction.test.ts index 4c37b94030..fbb5b3bc6a 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-reply-satisfaction.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-reply-satisfaction.test.ts @@ -280,37 +280,6 @@ describe('Slack reply satisfaction state', () => { }); }); - it('clears prior non-Slack-work markers when the current turn is satisfied again', () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'roomote-slack-')); - tempDirs.push(tempDir); - const stateFilePath = path.join(tempDir, 'reply-state.json'); - process.env[SLACK_REPLY_SATISFACTION_STATE_FILE_ENV] = stateFilePath; - fs.writeFileSync( - stateFilePath, - JSON.stringify({ - currentTurnMessageTs: '111.222', - currentTurnStartedAtMs: 1000, - lastNonSlackWorkAfterSatisfactionAtMs: 1200, - }), - 'utf8', - ); - - recordSlackReplySatisfaction({ - messageTs: '111.222', - tool: 'send_chat_reaction_emoji', - nowMs: 1234, - }); - - expect(JSON.parse(fs.readFileSync(stateFilePath, 'utf8'))).toEqual({ - currentTurnMessageTs: '111.222', - currentTurnStartedAtMs: 1000, - messageTs: '111.222', - tool: 'send_chat_reaction_emoji', - recordedAtMs: 1234, - satisfiedTurnMessageTs: '111.222', - }); - }); - it('writes terminal closeout state when a current-turn closeout reply is recorded', () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'roomote-slack-')); tempDirs.push(tempDir); @@ -503,7 +472,6 @@ describe('Slack reply satisfaction state', () => { startedAtMs: 1000, currentTurnMessageTs: '222.333', currentTurnStartedAtMs: 1500, - lastSilenceReminderAtMs: 2000, }), 'utf8', ); @@ -519,7 +487,6 @@ describe('Slack reply satisfaction state', () => { startedAtMs: 1000, currentTurnMessageTs: '222.333', currentTurnStartedAtMs: 1500, - lastSilenceReminderAtMs: 2000, messageTs: '333.444', tool: 'send_chat_reply', replyPurpose: 'progress', diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts index 9dfc4c9fa1..5a48bef366 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts @@ -574,7 +574,7 @@ describe('roomote MCP tool descriptions', () => { 'Choose the current Slack turn purpose before writing: ack, progress, closeout, or clarification.', ); expect(replyTool.config.description).toContain( - `Use ack for the first visible response when work will continue; use progress only when the message adds new decision-useful state or prevents a 10-minute silence gap; use closeout for the answer, result, blocker, or handoff; use clarification for lightweight non-secret questions. Use closeout to finish a turn with an outcome; a clarification also ends the turn when the next step depends on the user's answer — do not follow it with a separate "waiting on your answer" message. Ack and progress keep the Slack turn open.`, + `Use ack for the first visible response when work will continue; use progress only when the message adds new decision-useful state; use closeout for the answer, result, blocker, or handoff; use clarification for lightweight non-secret questions. Use closeout to finish a turn with an outcome; a clarification also ends the turn when the next step depends on the user's answer — do not follow it with a separate "waiting on your answer" message. Ack and progress keep the Slack turn open.`, ); expect(replyTool.config.description).toContain( "For routine successful closeouts, focus on the shipped change and any blocker or delivery outcome that changes the user's next step; do not include exact validation commands, passed-check ledgers, or proof-applicability narration unless the user asked or that detail materially changes what they should do next.", @@ -598,7 +598,7 @@ describe('roomote MCP tool descriptions', () => { "Non-empty Markdown text to post in the Slack thread. Match the selected purpose, lead with the useful takeaway, and keep it conversational like a teammate in a thread. For routine successful closeouts, focus on the shipped change and any blocker or delivery outcome that changes the user's next step instead of listing exact validation commands, passed checks, or proof-applicability notes unless the user asked for them or they materially change what the user should do next. Use the modern Slack Markdown contract from the Slack instructions; tables, headings, blockquotes, and fenced code blocks are allowed when they make the reply clearer.", ); expect(getInputSchemaField(replyTool, 'purpose').description).toBe( - 'The lifecycle purpose for this Slack-visible reply. Choose ack for the first visible response before work that will not post to Slack, progress for new useful state or silence prevention, closeout for the final answer/result/blocker/handoff, or clarification for a lightweight question. Use closeout before final task completion.', + 'The lifecycle purpose for this Slack-visible reply. Choose ack for the first visible response before work that will not post to Slack, progress for new useful state, closeout for the final answer/result/blocker/handoff, or clarification for a lightweight question. Use closeout before final task completion.', ); expect(replyTool.config.inputSchema.findings).toBeUndefined(); expect(replyTool.config.inputSchema.questions).toBeUndefined(); @@ -898,7 +898,7 @@ describe('roomote MCP tool descriptions', () => { expect(description).toContain('complete engineering handoff'); expect(description).toContain('do not send another generic ack'); expect(description).toContain('meaningful work milestones'); - expect(description).toContain('roughly 10 minutes of silence'); + expect(description).not.toContain('10 minutes'); expect(description).toContain( 'without labeling the message as a progress update', ); @@ -906,6 +906,9 @@ describe('roomote MCP tool descriptions', () => { expect(getInputSchemaField(reportTool, 'message').description).toContain( 'Non-empty Markdown report for the parent Session.', ); + expect( + getInputSchemaField(reportTool, 'purpose').description, + ).not.toContain('10 minutes'); expect(reportTool.config.inputSchema.suggestions).toBeUndefined(); expect( getRegisteredTool(registeredTools, 'manage_artifacts').config.description, @@ -1124,13 +1127,13 @@ describe('roomote MCP tool descriptions', () => { const chatReplyTool = getRegisteredTool(registeredTools, 'send_chat_reply'); expect(chatReplyTool.config.description).toBe( - `Slack-visible: posts a lifecycle reply in the originating Slack thread. Choose the current Slack turn purpose before writing: ack, progress, closeout, or clarification. Use ack for the first visible response when work will continue; use progress only when the message adds new decision-useful state or prevents a 10-minute silence gap; use closeout for the answer, result, blocker, or handoff; use clarification for lightweight non-secret questions. Use closeout to finish a turn with an outcome; a clarification also ends the turn when the next step depends on the user's answer — do not follow it with a separate "waiting on your answer" message. Ack and progress keep the Slack turn open. Use it again on later Slack turns when they need another direct reply; an earlier thread reply does not count as the reply for the current turn. For routine successful closeouts, focus on the shipped change and any blocker or delivery outcome that changes the user's next step; do not include exact validation commands, passed-check ledgers, or proof-applicability narration unless the user asked or that detail materially changes what they should do next. Supports the modern Slack Markdown contract from the Slack instructions. Use rich Markdown when it improves scanability. When the reply mentions actionable code references, follow the Slack prompt source-linking rule. Write the message so its content clearly matches the selected purpose.`, + `Slack-visible: posts a lifecycle reply in the originating Slack thread. Choose the current Slack turn purpose before writing: ack, progress, closeout, or clarification. Use ack for the first visible response when work will continue; use progress only when the message adds new decision-useful state; use closeout for the answer, result, blocker, or handoff; use clarification for lightweight non-secret questions. Use closeout to finish a turn with an outcome; a clarification also ends the turn when the next step depends on the user's answer — do not follow it with a separate "waiting on your answer" message. Ack and progress keep the Slack turn open. Use it again on later Slack turns when they need another direct reply; an earlier thread reply does not count as the reply for the current turn. For routine successful closeouts, focus on the shipped change and any blocker or delivery outcome that changes the user's next step; do not include exact validation commands, passed-check ledgers, or proof-applicability narration unless the user asked or that detail materially changes what they should do next. Supports the modern Slack Markdown contract from the Slack instructions. Use rich Markdown when it improves scanability. When the reply mentions actionable code references, follow the Slack prompt source-linking rule. Write the message so its content clearly matches the selected purpose.`, ); expect(getInputSchemaField(chatReplyTool, 'message').description).toBe( "Non-empty Markdown text to post in the Slack thread. Match the selected purpose, lead with the useful takeaway, and keep it conversational like a teammate in a thread. For routine successful closeouts, focus on the shipped change and any blocker or delivery outcome that changes the user's next step instead of listing exact validation commands, passed checks, or proof-applicability notes unless the user asked for them or they materially change what the user should do next. Use the modern Slack Markdown contract from the Slack instructions; tables, headings, blockquotes, and fenced code blocks are allowed when they make the reply clearer.", ); expect(getInputSchemaField(chatReplyTool, 'purpose').description).toBe( - 'The lifecycle purpose for this Slack-visible reply. Choose ack for the first visible response before work that will not post to Slack, progress for new useful state or silence prevention, closeout for the final answer/result/blocker/handoff, or clarification for a lightweight question. Use closeout before final task completion.', + 'The lifecycle purpose for this Slack-visible reply. Choose ack for the first visible response before work that will not post to Slack, progress for new useful state, closeout for the final answer/result/blocker/handoff, or clarification for a lightweight question. Use closeout before final task completion.', ); expect(chatReplyTool.config.description).not.toContain( '', diff --git a/apps/worker/src/mcp/roomote-mcp-server/chat-reply-satisfaction.ts b/apps/worker/src/mcp/roomote-mcp-server/chat-reply-satisfaction.ts index 9ffc4e827a..2479b8d4b0 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/chat-reply-satisfaction.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/chat-reply-satisfaction.ts @@ -37,12 +37,10 @@ interface ChatReplySatisfactionState { replyPurpose?: ChatReplyPurpose; recordedAtMs?: number; satisfiedTurnMessageTs?: string; - lastNonSlackWorkAfterSatisfactionAtMs?: number; terminalSatisfiedTurnMessageTs?: string; terminalSatisfiedAtMs?: number; terminalSatisfactionTool?: 'send_chat_reply' | 'report_to_parent_session'; lastNonSlackWorkAfterTerminalAtMs?: number; - lastSilenceReminderAtMs?: number; /** Failed chat delivery attempts since the last successful post this turn. */ deliveryFailureCount?: number; lastDeliveryFailureAtMs?: number; @@ -160,7 +158,6 @@ export function recordChatTurnStart(input: { ? { initialAckReminderAtMs: undefined, satisfiedTurnMessageTs: undefined, - lastNonSlackWorkAfterSatisfactionAtMs: undefined, terminalSatisfiedTurnMessageTs: undefined, terminalSatisfiedAtMs: undefined, terminalSatisfactionTool: undefined, @@ -315,7 +312,6 @@ export function recordChatReplySatisfaction(input: { ...(satisfiesCurrentTurn ? { satisfiedTurnMessageTs: currentTurnMessageTs, - lastNonSlackWorkAfterSatisfactionAtMs: undefined, } : {}), // A clarification reply is a terminal handoff like a closeout: the turn diff --git a/apps/worker/src/mcp/roomote-mcp-server/index.ts b/apps/worker/src/mcp/roomote-mcp-server/index.ts index 8eb3d64ac5..fc09090309 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/index.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/index.ts @@ -1600,8 +1600,8 @@ if ( ? 'Use the optional suggestions parameter when the automation prompt explicitly asks for task suggestions, launchable follow-ups, or help taking concrete actions. Do not infer suggested-task intent from a request that only asks for a summary or action-item list. Suggestions are posted inside the originating conversation. Do not use suggestions for ordinary summary bullets, status updates, questions, speculative ideas, or work explicitly identified in the conversation as already underway. When suggestions are present, the tool automatically adds the surface-specific instruction for starting one; do not write a separate launch instruction. ' : ''; const chatReplyDescription = reportsToParentSession - ? 'Session-internal: reports lifecycle information privately to the parent Session, which owns any user-visible reply. The report may be a complete engineering handoff and is never posted directly to the user. The kickoff already acknowledged the request, so do not send another generic ack. Use progress to pass concrete findings, blockers, meaningful work milestones, required input, or a brief note after roughly 10 minutes of silence. Describe the work itself without labeling the message as a progress update or using policy vocabulary such as phase transition, checkpoint, lifecycle, or user-facing. Use closeout for the final result or blocker and clarification when user input is needed. Ack and progress keep the coding task active.' - : `${chatReplySurfaceLabel}-visible: posts a lifecycle reply in the originating ${chatReplySurfaceLabel} thread. Choose the current ${chatReplySurfaceLabel} turn purpose before writing: ack, progress, closeout, or clarification. Use ack for the first visible response when work will continue; use progress only when the message adds new decision-useful state or prevents a 10-minute silence gap; use closeout for the answer, result, blocker, or handoff; use clarification for lightweight non-secret questions. Use closeout to finish a turn with an outcome; a clarification also ends the turn when the next step depends on the user's answer — do not follow it with a separate "waiting on your answer" message. Ack and progress keep the ${chatReplySurfaceLabel} turn open. Use it again on later ${chatReplySurfaceLabel} turns when they need another direct reply; an earlier thread reply does not count as the reply for the current turn. For routine successful closeouts, focus on the shipped change and any blocker or delivery outcome that changes the user's next step; do not include exact validation commands, passed-check ledgers, or proof-applicability narration unless the user asked or that detail materially changes what they should do next. ${chatReplyMarkdownGuidance}${chatReplySourceLinkingGuidance}${chatReplyEmailCadenceGuidance}${chatReplySuggestionGuidance}Write the message so its content clearly matches the selected purpose.`; + ? 'Session-internal: reports lifecycle information privately to the parent Session, which owns any user-visible reply. The report may be a complete engineering handoff and is never posted directly to the user. The kickoff already acknowledged the request, so do not send another generic ack. Use progress to pass concrete findings, blockers, meaningful work milestones, or required input. Describe the work itself without labeling the message as a progress update or using policy vocabulary such as phase transition, checkpoint, lifecycle, or user-facing. Use closeout for the final result or blocker and clarification when user input is needed. Ack and progress keep the coding task active.' + : `${chatReplySurfaceLabel}-visible: posts a lifecycle reply in the originating ${chatReplySurfaceLabel} thread. Choose the current ${chatReplySurfaceLabel} turn purpose before writing: ack, progress, closeout, or clarification. Use ack for the first visible response when work will continue; use progress only when the message adds new decision-useful state; use closeout for the answer, result, blocker, or handoff; use clarification for lightweight non-secret questions. Use closeout to finish a turn with an outcome; a clarification also ends the turn when the next step depends on the user's answer — do not follow it with a separate "waiting on your answer" message. Ack and progress keep the ${chatReplySurfaceLabel} turn open. Use it again on later ${chatReplySurfaceLabel} turns when they need another direct reply; an earlier thread reply does not count as the reply for the current turn. For routine successful closeouts, focus on the shipped change and any blocker or delivery outcome that changes the user's next step; do not include exact validation commands, passed-check ledgers, or proof-applicability narration unless the user asked or that detail materially changes what they should do next. ${chatReplyMarkdownGuidance}${chatReplySourceLinkingGuidance}${chatReplyEmailCadenceGuidance}${chatReplySuggestionGuidance}Write the message so its content clearly matches the selected purpose.`; roomoteMcpServer.registerTool( lifecycleToolName, { @@ -1614,8 +1614,8 @@ if ( .enum(['ack', 'progress', 'closeout', 'clarification']) .describe( reportsToParentSession - ? 'The lifecycle purpose of this private report to the parent Session. The kickoff already acknowledged the request, so avoid another generic ack. Use progress for concrete findings, blockers, meaningful work milestones, required input, or a brief update after roughly 10 minutes of silence. Use closeout for the final result or blocker and clarification when user input is needed.' - : `The lifecycle purpose for this ${chatReplySurfaceLabel}-visible reply. Choose ack for the first visible response before work that will not post to ${chatReplySurfaceLabel}, progress for new useful state or silence prevention, closeout for the final answer/result/blocker/handoff, or clarification for a lightweight question. Use closeout before final task completion.`, + ? 'The lifecycle purpose of this private report to the parent Session. The kickoff already acknowledged the request, so avoid another generic ack. Use progress for concrete findings, blockers, meaningful work milestones, or required input. Use closeout for the final result or blocker and clarification when user input is needed.' + : `The lifecycle purpose for this ${chatReplySurfaceLabel}-visible reply. Choose ack for the first visible response before work that will not post to ${chatReplySurfaceLabel}, progress for new useful state, closeout for the final answer/result/blocker/handoff, or clarification for a lightweight question. Use closeout before final task completion.`, ), message: nonEmptyStringSchema.describe( (reportsToParentSession diff --git a/apps/worker/src/run-task/__tests__/slack-silence-hook-script.test.ts b/apps/worker/src/run-task/__tests__/slack-silence-hook-script.test.ts index c3773b7290..72eb35bd79 100644 --- a/apps/worker/src/run-task/__tests__/slack-silence-hook-script.test.ts +++ b/apps/worker/src/run-task/__tests__/slack-silence-hook-script.test.ts @@ -7,8 +7,6 @@ import { SLACK_SILENCE_HOOK_SCRIPT } from '../slack-silence-hook-script'; describe('SLACK_SILENCE_HOOK_SCRIPT', () => { const tempDirs: string[] = []; - const reminder = - 'The originating chat thread has not received a visible update for this turn. Your next action must be a chat-visible update to the originating thread using send_chat_reply or use send_chat_reaction_emoji only when the latest user turn itself came from chat and that message can receive a reaction. If a successful visual-proof capture returned screenshot artifact IDs that are not yet visible in the thread and the update mentions or relies on that proof, include those IDs in the same reply via imageArtifactIds. Use request_user_input only when you genuinely require structured input from the user. Normal assistant messages do not count. Do not run more tools first. The only exception is tool_search when the needed chat reply/post tool is not visible. After sending the chat update, continue the work you were doing.'; const initialAckReminder = 'Before starting work that will not post to chat on this turn, send a quick chat-visible ack. When the latest user turn itself came from chat, reactions are allowed on that message, and a lightweight acknowledgement is enough, start with send_chat_reaction_emoji. Otherwise use send_chat_reply. Do not use request_user_input as a generic opening acknowledgement; only use it when you genuinely require structured input from the user. If the needed chat reply/post tool is not visible, use tool_search first. If context is still too thin to say anything concrete and the turn does not allow reactions, keep the text ack short and non-speculative. After that, continue the work you were doing.'; const subagentSlackPostDenial = @@ -68,8 +66,8 @@ describe('SLACK_SILENCE_HOOK_SCRIPT', () => { expect(result.stderr).toBe(''); }); - it('does nothing before seven minutes of Slack silence', () => { - const stateFilePath = writeState({ startedAtMs: Date.now() - 6 * 60_000 }); + it('does nothing when configured state needs no event-driven action', () => { + const stateFilePath = writeState({ startedAtMs: Date.now() }); const result = runHook({ env: { @@ -102,7 +100,59 @@ describe('SLACK_SILENCE_HOOK_SCRIPT', () => { expect(result.stderr).toContain('reason="parent_session_report_disabled"'); }); - it('skips Slack silence enforcement for non-parent subagent threads', () => { + it.each(['PreToolUse', 'PostToolUse'])( + 'does not enforce elapsed-time follow-through on %s', + (hookEventName) => { + const stateFilePath = writeState({ + recordedAtMs: Date.now() - 8 * 60_000, + messageTs: 'bot-111.222', + }); + + const result = runHook({ + input: { hook_event_name: hookEventName, tool_name: 'shell' }, + env: { + ROOMOTE_SLACK_HOOK_DEBUG: 'true', + ROOMOTE_SLACK_REPLY_SATISFACTION_STATE_FILE: stateFilePath, + }, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('decision="allow"'); + expect(result.stderr).toContain('reason="event_driven_checks_complete"'); + }, + ); + + it('keeps terminal-closeout bookkeeping for coding tasks', () => { + const stateFilePath = writeState({ + recordedAtMs: Date.now() - 60_000, + messageTs: 'bot-111.222', + tool: 'report_to_parent_session', + replyPurpose: 'closeout', + satisfiedTurnMessageTs: 'web:client-1', + currentTurnMessageTs: 'web:client-1', + terminalSatisfiedTurnMessageTs: 'web:client-1', + terminalSatisfiedAtMs: Date.now() - 60_000, + terminalSatisfactionTool: 'report_to_parent_session', + }); + + const result = runHook({ + input: { hook_event_name: 'PostToolUse', tool_name: 'shell' }, + env: { + ROOMOTE_FAST_AGENT_CHILD: 'true', + ROOMOTE_SLACK_REPLY_SATISFACTION_STATE_FILE: stateFilePath, + }, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect( + JSON.parse(fs.readFileSync(stateFilePath, 'utf8')) + .lastNonSlackWorkAfterTerminalAtMs, + ).toEqual(expect.any(Number)); + }); + + it('allows non-posting tools for non-parent subagent threads', () => { const stateFilePath = writeState({ parentThreadId: 'thread-parent', currentTurnMessageTs: 'user-111.222', @@ -326,13 +376,13 @@ describe('SLACK_SILENCE_HOOK_SCRIPT', () => { expect(result.status).toBe(0); expect(result.stdout).toBe(''); expect(result.stderr).toContain('decision="allow"'); - expect(result.stderr).toContain('reason="silence_below_threshold"'); + expect(result.stderr).toContain('reason="event_driven_checks_complete"'); expect( JSON.parse(fs.readFileSync(stateFilePath, 'utf8')), ).not.toHaveProperty('initialAckReminderAtMs'); }); - it('does not apply the stale-silence blocker before a late-bound automation run has posted its first reply', () => { + it('allows late-bound automation work before the first reply', () => { const stateFilePath = writeState({ startedAtMs: Date.now() - 8 * 60_000, currentTurnRequiresInitialAck: false, @@ -349,7 +399,7 @@ describe('SLACK_SILENCE_HOOK_SCRIPT', () => { expect(result.status).toBe(0); expect(result.stdout).toBe(''); expect(result.stderr).toContain('decision="allow"'); - expect(result.stderr).toContain('reason="missing_activity_timestamp"'); + expect(result.stderr).toContain('reason="event_driven_checks_complete"'); }); it.each(['ack', 'progress'])( @@ -446,43 +496,9 @@ describe('SLACK_SILENCE_HOOK_SCRIPT', () => { expect(result.stderr).toBe(''); }); - it('blocks PostToolUse after seven minutes of Slack silence', () => { - const stateFilePath = writeState({ - startedAtMs: Date.now() - 7 * 60_000 - 1_000, - }); - - const result = runHook({ - input: { hook_event_name: 'PostToolUse', tool_name: 'shell' }, - env: { - ROOMOTE_SLACK_REPLY_SATISFACTION_STATE_FILE: stateFilePath, - }, - }); - - expect(result.status).toBe(0); - expect(JSON.parse(result.stdout)).toEqual({ - continue: false, - decision: 'block', - reason: reminder, - stopReason: reminder, - hookSpecificOutput: { - hookEventName: 'PostToolUse', - additionalContext: reminder, - }, - }); - expect( - JSON.parse(fs.readFileSync(stateFilePath, 'utf8')) - .lastSilenceReminderAtMs, - ).toEqual(expect.any(Number)); - expect(result.stderr).toContain('INFO [SlackHook] Hook decision'); - expect(result.stderr).toContain('hook="slack-silence"'); - expect(result.stderr).toContain('trigger="PostToolUse"'); - expect(result.stderr).toContain('decision="block"'); - expect(result.stderr).toContain('reason="slack_update_overdue"'); - }); - it('stands down entirely when delivery to the bound channel has permanently failed', () => { const stateFilePath = writeState({ - startedAtMs: Date.now() - 7 * 60_000 - 1_000, + startedAtMs: Date.now(), deliveryFailureCount: 5, lastDeliveryFailureCode: 'not_in_channel', terminalDeliveryFailureAtMs: Date.now() - 5_000, @@ -502,168 +518,6 @@ describe('SLACK_SILENCE_HOOK_SCRIPT', () => { expect(result.stderr).toContain('reason="terminal_delivery_failure"'); }); - it('names the communication surface in the silence reminder for non-Slack providers', () => { - const stateFilePath = writeState({ - startedAtMs: Date.now() - 7 * 60_000 - 1_000, - }); - - const result = runHook({ - input: { hook_event_name: 'PostToolUse', tool_name: 'shell' }, - env: { - ROOMOTE_SLACK_REPLY_SATISFACTION_STATE_FILE: stateFilePath, - ROOMOTE_COMMUNICATION_PROVIDER: 'discord', - }, - }); - - expect(result.status).toBe(0); - const decision = JSON.parse(result.stdout); - expect(decision.reason).toContain( - 'The originating Discord thread has not received a visible update', - ); - expect(decision.reason).toContain('Discord-visible update'); - expect(decision.reason).not.toContain('Slack'); - }); - - it('continues blocking stale non-Slack hook events until another successful Slack reply is recorded', () => { - const lastActivityMs = Date.now() - 7 * 60_000 - 1_000; - const stateFilePath = writeState({ - recordedAtMs: lastActivityMs, - messageTs: '111.222', - lastSilenceReminderAtMs: lastActivityMs + 1_000, - }); - - const result = runHook({ - env: { - ROOMOTE_SLACK_REPLY_SATISFACTION_STATE_FILE: stateFilePath, - }, - }); - - expect(result.status).toBe(0); - expect(JSON.parse(result.stdout)).toMatchObject({ - decision: 'block', - reason: reminder, - }); - expect(result.stderr).toContain('decision="block"'); - }); - - it('measures silence from an unsatisfied current Slack turn instead of the previous reply', () => { - const stateFilePath = writeState({ - recordedAtMs: Date.now() - 60_000, - messageTs: 'bot-111.222', - satisfiedTurnMessageTs: 'user-111.222', - currentTurnMessageTs: 'user-222.333', - currentTurnStartedAtMs: Date.now() - 7 * 60_000 - 1_000, - }); - - const result = runHook({ - env: { - ROOMOTE_SLACK_REPLY_SATISFACTION_STATE_FILE: stateFilePath, - }, - }); - - expect(result.status).toBe(0); - expect(JSON.parse(result.stdout)).toEqual({ - continue: false, - decision: 'block', - reason: reminder, - stopReason: reminder, - hookSpecificOutput: { - hookEventName: 'PostToolUse', - additionalContext: reminder, - }, - }); - expect(result.stderr).toContain('decision="block"'); - }); - - it('blocks PreToolUse for non-Slack tools after seven minutes of Slack silence', () => { - const stateFilePath = writeState({ - recordedAtMs: Date.now() - 7 * 60_000 - 1_000, - messageTs: 'bot-111.222', - satisfiedTurnMessageTs: 'user-111.222', - currentTurnMessageTs: 'user-111.222', - }); - - const result = runHook({ - input: { hook_event_name: 'PreToolUse', tool_name: 'shell' }, - env: { - ROOMOTE_SLACK_REPLY_SATISFACTION_STATE_FILE: stateFilePath, - }, - }); - - expect(result.status).toBe(0); - expect(JSON.parse(result.stdout)).toEqual({ - decision: 'block', - reason: reminder, - }); - expect(result.stderr).toContain('trigger="PreToolUse"'); - expect(result.stderr).toContain('tool="shell"'); - }); - - it('allows PreToolUse for tool_search after seven minutes of Slack silence', () => { - const stateFilePath = writeState({ - recordedAtMs: Date.now() - 7 * 60_000 - 1_000, - messageTs: 'bot-111.222', - satisfiedTurnMessageTs: 'user-111.222', - currentTurnMessageTs: 'user-111.222', - }); - - const result = runHook({ - input: { - hook_event_name: 'PreToolUse', - tool_name: 'tool_search.tool_search_tool', - }, - env: { - ROOMOTE_SLACK_REPLY_SATISFACTION_STATE_FILE: stateFilePath, - }, - }); - - expect(result.status).toBe(0); - expect(result.stdout).toBe(''); - expect(result.stderr).toBe(''); - }); - - it('allows PreToolUse for Slack reply tools after seven minutes of Slack silence', () => { - const stateFilePath = writeState({ - recordedAtMs: Date.now() - 7 * 60_000 - 1_000, - messageTs: 'bot-111.222', - }); - - const result = runHook({ - input: { - hook_event_name: 'PreToolUse', - tool_name: 'mcp__roomote__send_chat_reply', - }, - env: { - ROOMOTE_SLACK_REPLY_SATISFACTION_STATE_FILE: stateFilePath, - }, - }); - - expect(result.status).toBe(0); - expect(result.stdout).toBe(''); - expect(result.stderr).toBe(''); - }); - - it('allows PreToolUse for the current-turn Slack reaction shortcut after seven minutes of Slack silence', () => { - const stateFilePath = writeState({ - recordedAtMs: Date.now() - 7 * 60_000 - 1_000, - messageTs: 'bot-111.222', - currentTurnMessageTs: '111.222', - }); - - const result = runHook({ - input: { - hook_event_name: 'PreToolUse', - tool_name: 'mcp__roomote__send_chat_reaction_emoji', - }, - env: { - ROOMOTE_SLACK_REPLY_SATISFACTION_STATE_FILE: stateFilePath, - }, - }); - - expect(result.status).toBe(0); - expect(result.stdout).toBe(''); - }); - it('blocks the current-turn Slack reaction shortcut for a web-originated turn', () => { const stateFilePath = writeState({ currentTurnMessageTs: 'web:client-1', @@ -689,31 +543,6 @@ describe('SLACK_SILENCE_HOOK_SCRIPT', () => { expect(result.stderr).toContain('reason="initial_slack_ack_missing"'); }); - it('blocks request_user_input after seven minutes of Slack silence until a real Slack lifecycle reply is sent', () => { - const stateFilePath = writeState({ - recordedAtMs: Date.now() - 7 * 60_000 - 1_000, - messageTs: 'bot-111.222', - }); - - const result = runHook({ - input: { - hook_event_name: 'PreToolUse', - tool_name: 'request_user_input', - }, - env: { - ROOMOTE_SLACK_REPLY_SATISFACTION_STATE_FILE: stateFilePath, - }, - }); - - expect(result.status).toBe(0); - expect(JSON.parse(result.stdout)).toEqual({ - decision: 'block', - reason: reminder, - }); - expect(result.stderr).toContain('decision="block"'); - expect(result.stderr).toContain('reason="slack_update_overdue"'); - }); - it('records non-Slack work after a terminal closeout on PostToolUse', () => { const stateFilePath = writeState({ recordedAtMs: Date.now() - 60_000, @@ -791,35 +620,6 @@ describe('SLACK_SILENCE_HOOK_SCRIPT', () => { ).toBeUndefined(); }); - it('records non-Slack work after a reaction ack on PostToolUse', () => { - const nowMs = Date.now(); - const stateFilePath = writeState({ - recordedAtMs: nowMs, - messageTs: 'user-111.222', - tool: 'send_chat_reaction_emoji', - satisfiedTurnMessageTs: 'web:client-1', - currentTurnMessageTs: 'web:client-1', - }); - - const result = runHook({ - input: { hook_event_name: 'PostToolUse', tool_name: 'shell' }, - env: { - ROOMOTE_SLACK_REPLY_SATISFACTION_STATE_FILE: stateFilePath, - }, - }); - - expect(result.status).toBe(0); - expect(result.stdout).toBe(''); - expect( - JSON.parse(fs.readFileSync(stateFilePath, 'utf8')) - .lastNonSlackWorkAfterSatisfactionAtMs, - ).toBeGreaterThan(nowMs); - expect( - JSON.parse(fs.readFileSync(stateFilePath, 'utf8')) - .lastNonSlackWorkAfterTerminalAtMs, - ).toBeUndefined(); - }); - it('ignores update_plan bookkeeping after a terminal closeout on PostToolUse', () => { const stateFilePath = writeState({ recordedAtMs: Date.now() - 60_000, @@ -912,10 +712,6 @@ describe('SLACK_SILENCE_HOOK_SCRIPT', () => { expect(result.status).toBe(0); expect(result.stdout).toBe(''); - expect( - JSON.parse(fs.readFileSync(stateFilePath, 'utf8')) - .lastNonSlackWorkAfterSatisfactionAtMs, - ).toBeUndefined(); expect( JSON.parse(fs.readFileSync(stateFilePath, 'utf8')) .lastNonSlackWorkAfterTerminalAtMs, @@ -941,6 +737,6 @@ describe('SLACK_SILENCE_HOOK_SCRIPT', () => { expect(result.stdout).toBe(''); expect(result.stderr).toContain('INFO [SlackHook] Hook decision'); expect(result.stderr).toContain('decision="allow"'); - expect(result.stderr).toContain('reason="silence_below_threshold"'); + expect(result.stderr).toContain('reason="event_driven_checks_complete"'); }); }); diff --git a/apps/worker/src/run-task/mcp-task-env.ts b/apps/worker/src/run-task/mcp-task-env.ts index 00df0bcd27..97920ebb04 100644 --- a/apps/worker/src/run-task/mcp-task-env.ts +++ b/apps/worker/src/run-task/mcp-task-env.ts @@ -166,9 +166,8 @@ export function buildMcpTaskEnv(input: { ) { // Telegram, Teams, and Discord tasks use the same turn-satisfaction // machinery as Slack (ack/closeout enforcement and current-turn emoji - // reactions). AgentMail (email) is deliberately excluded: email is a - // low-frequency surface with no reactions, and it must never get the - // silence-reminder heartbeats this state file drives. + // reactions). AgentMail (email) is deliberately excluded because it is + // a low-frequency surface with no reactions. mcpTaskEnv.ROOMOTE_SLACK_REPLY_SATISFACTION_STATE_FILE ??= [ input.runtimeEnv.HOME ?? '/tmp', '.config', diff --git a/apps/worker/src/run-task/slack-silence-hook-script.ts b/apps/worker/src/run-task/slack-silence-hook-script.ts index ffb7533780..b59fd6d7be 100644 --- a/apps/worker/src/run-task/slack-silence-hook-script.ts +++ b/apps/worker/src/run-task/slack-silence-hook-script.ts @@ -39,40 +39,6 @@ const REPORTS_TO_PARENT_SESSION = const LIFECYCLE_TOOL_NAME = REPORTS_TO_PARENT_SESSION ? 'report_to_parent_session' : 'send_chat_reply'; -const REMINDER = REPORTS_TO_PARENT_SESSION - ? [ - 'The parent Session has not received a substantive update for this turn.', - 'Your next action must be a private report using report_to_parent_session.', - 'If the report mentions or relies on visual proof, include the relevant', - 'screenshot artifact IDs via imageArtifactIds.', - 'Normal assistant messages do not count. Do not run more tools first.', - 'After reporting, continue the work you were doing.', - ].join(' ') - : [ - 'The originating ' + - SURFACE_LABEL + - ' thread has not received a visible update for this turn. Your next action', - 'must be a ' + - SURFACE_LABEL + - '-visible update to the originating thread using', - 'send_chat_reply', - 'or use send_chat_reaction_emoji only when the latest user turn itself came', - 'from ' + SURFACE_LABEL + ' and that message can receive a reaction.', - 'If a successful visual-proof capture returned screenshot artifact IDs that', - 'are not yet visible in the thread and the update mentions or relies on that', - 'proof, include those IDs in the same reply via imageArtifactIds.', - 'Use request_user_input only when you genuinely require structured input', - 'from the user.', - 'Normal assistant messages do not count.', - 'Do not run more tools first.', - 'The only exception is tool_search when the needed ' + - SURFACE_LABEL + - ' reply/post tool is', - 'not visible.', - 'After sending the ' + - SURFACE_LABEL + - ' update, continue the work you were doing.', - ].join(' '); const INITIAL_ACK_REMINDER = REPORTS_TO_PARENT_SESSION ? [ 'The parent Session needs a substantive update before more work.', @@ -105,7 +71,6 @@ const SUBAGENT_SLACK_POST_DENIAL = [ 'Return your findings in your final report to the parent agent instead;', 'the parent agent will relay any ' + SURFACE_LABEL + '-visible update.', ].join(' '); -const MAX_SILENCE_MS = 7 * 60 * 1000; const HOOK_NAME = 'slack-silence'; const HOOK_DEBUG_ENV = 'ROOMOTE_SLACK_HOOK_DEBUG'; const SLACK_MESSAGE_TS_REGEX = /^\\d+\\.\\d+$/; @@ -491,62 +456,6 @@ function logAllow(fields) { }); } -function getLastActivityMs(state) { - if (!state || typeof state !== 'object') { - return null; - } - - const currentTurnRequiresInitialAck = - state.currentTurnRequiresInitialAck !== false; - const currentTurnMessageTs = trimString(state.currentTurnMessageTs); - const satisfiedTurnMessageTs = trimString(state.satisfiedTurnMessageTs); - const recordedAtMs = readFiniteMs(state.recordedAtMs); - - if ( - !currentTurnRequiresInitialAck && - !currentTurnMessageTs && - recordedAtMs === null - ) { - return null; - } - - if ( - currentTurnMessageTs && - satisfiedTurnMessageTs !== currentTurnMessageTs && - readFiniteMs(state.currentTurnStartedAtMs) !== null - ) { - return state.currentTurnStartedAtMs; - } - - const lastNonSlackWorkAfterTerminalAtMs = readFiniteMs( - state.lastNonSlackWorkAfterTerminalAtMs, - ); - if (lastNonSlackWorkAfterTerminalAtMs !== null) { - return lastNonSlackWorkAfterTerminalAtMs; - } - - if (recordedAtMs !== null) { - return recordedAtMs; - } - - const startedAtMs = readFiniteMs(state.startedAtMs); - if (startedAtMs !== null) { - return startedAtMs; - } - - return null; -} - -function writeReminderState(stateFilePath, state, nowMs) { - const nextState = { - ...(state && typeof state === 'object' ? state : {}), - lastSilenceReminderAtMs: nowMs, - }; - - fs.mkdirSync(path.dirname(stateFilePath), { recursive: true }); - fs.writeFileSync(stateFilePath, JSON.stringify(nextState), 'utf8'); -} - function writeInitialAckReminderState(stateFilePath, state, nowMs) { const nextState = { ...(state && typeof state === 'object' ? state : {}), @@ -614,7 +523,7 @@ function writeInitialAckReminderState(stateFilePath, state, nowMs) { process.exit(0); } - // Delivery to the bound channel has permanently failed; ack and silence + // Delivery to the bound channel has permanently failed; acknowledgement // reminders would demand posts that cannot succeed, so stand down entirely. const terminalDeliveryFailureAtMs = readFiniteMs( state && state.terminalDeliveryFailureAtMs, @@ -656,16 +565,12 @@ function writeInitialAckReminderState(stateFilePath, state, nowMs) { !isSlackSatisfactionTool(hookInput, state) && !isRequestUserInputTool(hookInput) && !isCloseoutBookkeepingTool(hookInput) && - (hasCurrentTurnSlackAck(state) || + (hasCurrentTurnTerminalCloseout(state) || hasNoTurnAutomationTerminalCloseout(state)) ) { state = { ...(state && typeof state === 'object' ? state : {}), - lastNonSlackWorkAfterSatisfactionAtMs: nowMs, - ...(hasCurrentTurnTerminalCloseout(state) || - hasNoTurnAutomationTerminalCloseout(state) - ? { lastNonSlackWorkAfterTerminalAtMs: nowMs } - : {}), + lastNonSlackWorkAfterTerminalAtMs: nowMs, }; writeState(stateFilePath, state); } @@ -697,76 +602,11 @@ function writeInitialAckReminderState(stateFilePath, state, nowMs) { process.exit(0); } - const lastActivityMs = getLastActivityMs(state); - if (lastActivityMs === null) { - logAllow({ - trigger: hookEventName, - reason: 'missing_activity_timestamp', - tool: getToolName(hookInput), - }); - process.exit(0); - } - - const silenceMs = nowMs - lastActivityMs; - if (silenceMs < MAX_SILENCE_MS) { - logAllow({ - trigger: hookEventName, - reason: 'silence_below_threshold', - tool: getToolName(hookInput), - silenceMs, - thresholdMs: MAX_SILENCE_MS, - }); - process.exit(0); - } - - if ( - hookEventName === 'PreToolUse' && - (isSlackSatisfactionTool(hookInput, state) || - isSlackReplyToolDiscoveryTool(hookInput)) - ) { - logAllow({ - trigger: hookEventName, - reason: isSlackReplyToolDiscoveryTool(hookInput) - ? 'reply_tool_discovery_allowed_while_over_threshold' - : 'reply_tool_allowed_while_over_threshold', - tool: getToolName(hookInput), - silenceMs, - thresholdMs: MAX_SILENCE_MS, - }); - process.exit(0); - } - - writeReminderState(stateFilePath, state, nowMs); - logDecision('INFO', { + logAllow({ trigger: hookEventName, - decision: 'block', tool: getToolName(hookInput), - reason: 'slack_update_overdue', - silenceMs, - thresholdMs: MAX_SILENCE_MS, - stateFilePath, + reason: 'event_driven_checks_complete', }); - if (hookEventName === 'PreToolUse') { - process.stdout.write( - JSON.stringify({ - decision: 'block', - reason: REMINDER, - }), - ); - process.exit(0); - } - - process.stdout.write( - JSON.stringify({ - continue: false, - decision: 'block', - reason: REMINDER, - stopReason: REMINDER, - hookSpecificOutput: { - hookEventName: 'PostToolUse', - additionalContext: REMINDER, - }, - }), - ); + process.exit(0); })(); `; diff --git a/packages/cloud-agents/src/server/workflows/__tests__/slackAppMention.test.ts b/packages/cloud-agents/src/server/workflows/__tests__/slackAppMention.test.ts index 66621cf39a..4f9535463f 100644 --- a/packages/cloud-agents/src/server/workflows/__tests__/slackAppMention.test.ts +++ b/packages/cloud-agents/src/server/workflows/__tests__/slackAppMention.test.ts @@ -109,7 +109,7 @@ describe('slackAppMention', () => { 'Do not use `request_user_input` as a generic opening acknowledgement; only use it when the task is already blocked on concrete input from the user.', ); expect(result.harnessInstructions).toContain( - '`progress`: After an acknowledgement, send progress only when the update adds decision-useful state since the last Slack-visible reply: a material result, blocker, input need, changed approach, meaningful phase transition, proof artifact, or a timed update that prevents more than 10 minutes of Slack-visible silence during active work. When that timed update is warranted, keep it brief and outcome-level: say what is materially true now and what happens next in user terms instead of turning Slack into a running work log.', + '`progress`: After an acknowledgement, send progress only when the update adds decision-useful state since the last Slack-visible reply: a material result, blocker, input need, changed approach, meaningful phase transition, or proof artifact. Keep it brief and outcome-level: say what is materially true now and what happens next in user terms instead of turning Slack into a running work log.', ); expect(result.harnessInstructions).toContain( 'When internal review, proof, or delegated helper steps create follow-up work, keep the update parent-owned and phase-based. Describe the current phase in human terms such as reviewing, tightening follow-ups, or final checking instead of naming the internal agent, review pass, or proof run unless that mechanism is itself the blocker or the user explicitly asked for it.', @@ -315,8 +315,9 @@ describe('slackAppMention', () => { expect(result.harnessInstructions).toContain( '`progress`: After an acknowledgement, send progress only when the update adds decision-useful state since the last Slack-visible reply', ); - expect(result.harnessInstructions).toContain( - 'prevents more than 10 minutes of Slack-visible silence during active work', + expect(result.harnessInstructions).not.toContain('timed update'); + expect(result.harnessInstructions).not.toContain( + 'minutes of Slack-visible silence', ); expect(result.harnessInstructions).toContain( 'For code-writing turns, the initial ack should say implementation is the next action when that is true and the agent already has enough inspected repository context to describe the work concretely', diff --git a/packages/cloud-agents/src/server/workflows/slackAppMention.ts b/packages/cloud-agents/src/server/workflows/slackAppMention.ts index 6de9e27cb9..074403e634 100644 --- a/packages/cloud-agents/src/server/workflows/slackAppMention.ts +++ b/packages/cloud-agents/src/server/workflows/slackAppMention.ts @@ -66,7 +66,7 @@ export function buildSlackMessageInstructions({ A Slack user turn has a small lifecycle: acknowledge the turn when needed, report useful progress when there is useful new state, and close out when there is an answer, result, blocker, or a clear paused-waiting state. Slack uses this lifecycle for user-visible replies instead of treating Slack as an intermediary-update surface. One Slack message can satisfy multiple lifecycle purposes only when its content genuinely does so. \`ack\`: Send one early Slack-visible acknowledgement before substantial work that will not post to Slack when the answer is not immediate. When the \`\` block says \`prefer_emoji_ack="true"\`, the latest directed user turn itself came from Slack, and a lightweight acknowledgement is enough, acknowledge with \`send_chat_reaction_emoji\`. When the acknowledgement needs words, the latest user turn did not come from Slack, or the policy disallows reactions, use \`send_chat_reply\`. Do not use \`request_user_input\` as a generic opening acknowledgement; only use it when the task is already blocked on concrete input from the user. If the first Slack-visible action already answers or completes the turn, that action is the acknowledgement and no separate ack is needed. - \`progress\`: After an acknowledgement, send progress only when the update adds decision-useful state since the last Slack-visible reply: a material result, blocker, input need, changed approach, meaningful phase transition, proof artifact, or a timed update that prevents more than 10 minutes of Slack-visible silence during active work. When that timed update is warranted, keep it brief and outcome-level: say what is materially true now and what happens next in user terms instead of turning Slack into a running work log. + \`progress\`: After an acknowledgement, send progress only when the update adds decision-useful state since the last Slack-visible reply: a material result, blocker, input need, changed approach, meaningful phase transition, or proof artifact. Keep it brief and outcome-level: say what is materially true now and what happens next in user terms instead of turning Slack into a running work log. When internal review, proof, or delegated helper steps create follow-up work, keep the update parent-owned and phase-based. Describe the current phase in human terms such as reviewing, tightening follow-ups, or final checking instead of naming the internal agent, review pass, or proof run unless that mechanism is itself the blocker or the user explicitly asked for it. When an active parent workflow delegates to a child skill and the parent still owns remaining proof, delivery, blocker handling, or final reporting, do not let the child satisfy the Slack closeout on its own. Treat that child completion as internal progress, keep any user-visible update parent-owned, and wait for the parent workflow's true terminal state before sending \`send_chat_reply\` with purpose \`closeout\`. \`closeout\`: Send one Slack-visible closeout when the turn has an answer, completed result, explicit blocker, or a paused-waiting state that you explain in prose. This is the only terminal \`send_chat_reply\` purpose. A \`request_user_input\` prompt or UI handoff never satisfies closeout on its own. If a prior Slack-visible reply already resolved the turn, the closeout can be brief and should make that outcome clear. @@ -197,7 +197,7 @@ export function buildChatProviderMessageInstructions( <${tag}_turn_lifecycle> A ${label} user turn has a small lifecycle: acknowledge the turn when needed, report useful progress when there is useful new state, and close out when there is an answer, result, blocker, or a clear paused-waiting state. ${label} uses this lifecycle for user-visible replies instead of treating ${label} as an intermediary-update surface. One ${label} message can satisfy multiple lifecycle purposes only when its content genuinely does so. \`ack\`: Send one early ${label}-visible acknowledgement before substantial work that will not otherwise post to ${label} when the answer is not immediate. When the current turn allows emoji reactions (see an optional \`<${tag}_turn_policy prefer_emoji_ack="true">\` block, or \`reactions_allowed="true"\` on the inbound message policy) and a lightweight acknowledgement is enough, prefer \`send_chat_reaction_emoji\` over a short text ack. When the ack needs words, reactions are not allowed, or this is the first chat turn of a task, use \`send_chat_reply\`. If the first ${label}-visible action already answers or completes the turn, that action is the acknowledgement and no separate ack is needed. - \`progress\`: After an acknowledgement, send progress only when the update adds decision-useful state since the last ${label}-visible reply: a material result, blocker, input need, changed approach, meaningful phase transition, proof artifact, or a timed update that prevents more than 10 minutes of ${label}-visible silence during active work. When that timed update is warranted, keep it brief and outcome-level: say what is materially true now and what happens next in user terms instead of turning ${label} into a running work log. + \`progress\`: After an acknowledgement, send progress only when the update adds decision-useful state since the last ${label}-visible reply: a material result, blocker, input need, changed approach, meaningful phase transition, or proof artifact. Keep it brief and outcome-level: say what is materially true now and what happens next in user terms instead of turning ${label} into a running work log. When internal review, proof, or delegated helper steps create follow-up work, keep the update parent-owned and phase-based. Describe the current phase in human terms such as reviewing, tightening follow-ups, or final checking instead of naming the internal agent, review pass, or proof run unless that mechanism is itself the blocker or the user explicitly asked for it. When an active parent workflow delegates to a child skill and the parent still owns remaining proof, delivery, blocker handling, or final reporting, do not let the child satisfy the ${label} closeout on its own. Treat that child completion as internal progress, keep any user-visible update parent-owned, and wait for the parent workflow's true terminal state before sending \`send_chat_reply\` with purpose \`closeout\`. \`closeout\`: Send one ${label}-visible closeout when the turn has an answer, completed result, explicit blocker, or a paused-waiting state that you explain in prose. This is the only terminal \`send_chat_reply\` purpose. A \`request_user_input\` prompt or UI handoff never satisfies closeout on its own. If a prior ${label}-visible reply already resolved the turn, the closeout can be brief and should make that outcome clear. Do not send another closeout that restates the same delivery outcome (for example the same PR link and self-review note) after internal bookkeeping, validation, or helper follow-up unless the user-visible outcome actually changed.