diff --git a/.archon/workflows/defaults/archon-slack-feature-to-review-app.yaml b/.archon/workflows/defaults/archon-slack-feature-to-review-app.yaml index 502ab5eb04..591c00f4a6 100644 --- a/.archon/workflows/defaults/archon-slack-feature-to-review-app.yaml +++ b/.archon/workflows/defaults/archon-slack-feature-to-review-app.yaml @@ -37,10 +37,16 @@ nodes: 1. Restate your understanding of the request in 1-2 sentences. 2. Explore the codebase briefly (CLAUDE.md, directory structure, files obviously related to the feature). - 3. Ask a tight set of 3-5 clarifying questions focused on DECISIONS - (scope boundaries, which existing code to extend, test - expectations, explicit out-of-scope items). - 4. End with: "Answer the questions and I'll draft a spec." + 3. Ask 3-5 clarifying questions as a structured form: + - Emit a fenced block with language tag `archon-questions`. + - Each question must include: + - `id` (snake_case) + - `type` (one of: `yes_no`, `yes_no_text`, `select`, `checkboxes`, `text`) + - `label` + - `select` and `checkboxes` must include `options` with `{ value, label }`. + - `required` is optional (defaults to true). + - `yes_no_text` may include `open_text_label`. + 4. End with: "Click **Answer questions** to submit your responses, and I'll draft a spec." 5. Do NOT emit the approval signal yet. ## If the user has replied: diff --git a/bun.lock b/bun.lock index 6a9957e14b..1e0384dab9 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "@slack/bolt": "^4.6.0", "discord.js": "^14.16.0", "grammy": "^1.36.0", + "js-yaml": "^4.1.0", "telegramify-markdown": "^1.3.0", }, "peerDependencies": { diff --git a/docs/plans/2026-04-20-slack-scoping-questions-form-plan.md b/docs/plans/2026-04-20-slack-scoping-questions-form-plan.md new file mode 100644 index 0000000000..2b64fe6c19 --- /dev/null +++ b/docs/plans/2026-04-20-slack-scoping-questions-form-plan.md @@ -0,0 +1,391 @@ +# Slack Scoping Questions Form Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Convert the first-iteration spec scoping questions in `archon-slack-feature-to-review-app` from free-text bullets into a Slack modal form with typed inputs, then feed submitted answers back into `$LOOP_USER_INPUT` as deterministic text. + +**Architecture:** Keep workflow-engine contracts unchanged. The workflow prompt emits a fenced `archon-questions` schema block on the first `spec` loop iteration, and the Slack adapter detects that block during `interactiveGate` rendering. If valid, it renders an "Answer questions" button that opens a modal; on submit, answers are flattened into labeled text and dispatched as a synthetic Slack message through the existing message pipeline. + +**Tech Stack:** Bun + TypeScript, Slack Bolt adapter (`@slack/bolt`), existing workflow YAML loop prompting, Bun test. + +**Related spec:** `.claude/archon/specs/2026-04-20-slack-scoping-questions-form.spec.md` + +--- + +## File Structure + +- Modify: `.archon/workflows/defaults/archon-slack-feature-to-review-app.yaml` + - Responsibility: Emit the `archon-questions` fenced schema on first iteration, preserving current approval semantics. +- Modify: `packages/adapters/src/chat/slack/adapter.ts` + - Responsibility: Parse/strip question schema, render question button + modal, process modal submission, fallback to existing gate behavior on invalid schema. +- Modify: `packages/adapters/src/chat/slack/adapter.test.ts` + - Responsibility: Validate new render paths, fallback behavior, modal submission formatting, and no-regression gate behavior. + +No new packages, no DB/schema changes, no workflow-engine API changes. + +--- + +### Task 1: Update Workflow Prompt Contract + +**Files:** +- Modify: `.archon/workflows/defaults/archon-slack-feature-to-review-app.yaml` (spec node prompt block) +- Test: `bun run validate workflows archon-slack-feature-to-review-app --json` + +- [x] **Step 1: Write failing contract assertion test command** + +Run: +```bash +bun run validate workflows archon-slack-feature-to-review-app --json +``` + +Expected now: PASS (baseline). Keep output for post-change comparison. + +- [x] **Step 2: Update first-iteration instructions to require `archon-questions` fenced YAML** + +Apply this prompt delta in the `spec.loop.prompt` first-iteration section: + +```yaml +## If this is the first iteration ($LOOP_USER_INPUT is empty): + +1. Restate your understanding of the request in 1-2 sentences. +2. Explore the codebase briefly (CLAUDE.md, directory structure, files obviously related to the feature). +3. Ask 3-5 clarifying questions as a structured form: + - Emit a fenced block with language tag `archon-questions`. + - Each question must include: + - `id` (snake_case) + - `type` (one of: `yes_no`, `yes_no_text`, `select`, `checkboxes`, `text`) + - `label` + - `select` and `checkboxes` must include `options` with `{ value, label }`. + - `required` is optional (defaults to true). + - `yes_no_text` may include `open_text_label`. +4. End with: "Click **Answer questions** to submit your responses, and I'll draft a spec." +5. Do NOT emit the approval signal yet. +``` + +- [x] **Step 3: Re-run workflow validation** + +Run: +```bash +bun run validate workflows archon-slack-feature-to-review-app --json +``` + +Expected: PASS with valid YAML parse and no schema errors. + +- [x] **Step 4: Commit prompt-only change** + +Run: +```bash +git add .archon/workflows/defaults/archon-slack-feature-to-review-app.yaml +git commit -m "feat(workflow): require structured archon-questions schema in spec loop" +``` + +--- + +### Task 2: Add Question-Schema Parse + Render Path in Slack Adapter + +**Files:** +- Modify: `packages/adapters/src/chat/slack/adapter.ts` +- Test: `packages/adapters/src/chat/slack/adapter.test.ts` + +- [x] **Step 1: Add constants and schema types** + +Add near existing gate constants: + +```ts +const GATE_ACTION_ANSWER_QUESTIONS = 'gate_answer_questions'; +const QUESTIONS_MODAL_CALLBACK = 'gate_questions_modal'; +const QUESTIONS_BLOCK_REGEX = /```archon-questions\\n([\\s\\S]*?)```/m; + +type QuestionType = 'yes_no' | 'yes_no_text' | 'select' | 'checkboxes' | 'text'; +type QuestionOption = { value: string; label: string }; +type QuestionDef = { + id: string; + type: QuestionType; + label: string; + required?: boolean; + options?: QuestionOption[]; + open_text_label?: string; +}; +``` + +- [x] **Step 2: Add parse + strip helpers with fail-soft semantics** + +Implement private helpers: + +```ts +private extractQuestionsBlock(message: string): { cleanedMessage: string; questions: QuestionDef[] | null } +private parseQuestionsYaml(raw: string): QuestionDef[] | null +private isValidQuestionDefArray(value: unknown): value is QuestionDef[] +``` + +Behavior requirements: +- Strip fenced block from rendered message in all cases. +- Return `questions: null` on malformed YAML / invalid shape. +- Log `slack.questions_schema_invalid` with reason at `warn`. +- Never throw from parsing path. + +- [x] **Step 3: Branch gate rendering in `sendWithMarkdownBlock`** + +Adjust `sendWithMarkdownBlock(...)`: +- Call `extractQuestionsBlock(message)` before block creation. +- Use `cleanedMessage` for markdown/text fallback. +- If `gate` exists and `questions` is valid: append one actions block from new `buildQuestionsActionsBlock(gate)`. +- Else if `gate` exists: append existing approve/request changes actions block. + +Add action block builder: + +```ts +private buildQuestionsActionsBlock(gate: { runId: string; nodeId: string }): SlackBlock +``` + +Button text: `Answer questions`; action id prefix `gate_answer_questions`. + +- [x] **Step 4: Run targeted unit tests (expected fail before Task 3 modal handlers)** + +Run: +```bash +bun test packages/adapters/src/chat/slack/adapter.test.ts +``` + +Expected at this stage: failing tests for unimplemented action/view handlers (if tests added ahead of implementation), or PASS for existing tests + new parser/render tests. + +- [x] **Step 5: Commit parse/render scaffolding** + +Run: +```bash +git add packages/adapters/src/chat/slack/adapter.ts packages/adapters/src/chat/slack/adapter.test.ts +git commit -m "feat(slack): render structured question gate when archon-questions schema is present" +``` + +--- + +### Task 3: Implement Questions Modal Open + Submit Handling + +**Files:** +- Modify: `packages/adapters/src/chat/slack/adapter.ts` +- Test: `packages/adapters/src/chat/slack/adapter.test.ts` + +- [x] **Step 1: Register new Slack action + modal callbacks** + +In `registerGateHandlers()` add: + +```ts +this.app.action( + { type: 'block_actions', action_id: new RegExp(`^${GATE_ACTION_ANSWER_QUESTIONS}\\|`) }, + async ({ ack, body, action, client }) => { + await ack(); + await this.handleAnswerQuestionsClick({ body, action, client }); + } +); + +this.app.view(QUESTIONS_MODAL_CALLBACK, async ({ ack, view, body }) => { + await ack(); + await this.handleQuestionsModalSubmit({ view, body }); +}); +``` + +- [x] **Step 2: Implement modal builder for all supported question types** + +Add helper: + +```ts +private buildQuestionsModalBlocks(questions: QuestionDef[]): SlackBlock[] +``` + +Mapping: +- `yes_no`: input + `radio_buttons` +- `yes_no_text`: one input block for radio + one optional multiline text input +- `select`: input + `static_select` +- `checkboxes`: input + `checkboxes` +- `text`: multiline `plain_text_input` + +Store `{ channel, threadTs, userId, questions }` in `private_metadata`. + +- [x] **Step 3: Implement `handleAnswerQuestionsClick`** + +Pattern after `handleRequestChangesClick`: +- Extract click context and trigger id. +- Decode action ids. +- Open modal with callback id `gate_questions_modal`. +- On open failure log `slack.questions_modal_open_failed`. + +- [x] **Step 4: Implement `handleQuestionsModalSubmit` + formatter** + +Add: + +```ts +private formatQuestionsAnswersForLoop( + questions: QuestionDef[], + values: Record< + string, + Record< + string, + { + value?: string; + selected_option?: { value?: string }; + selected_options?: Array<{ value?: string }>; + } + > + > +): string +``` + +Output format: +- Header `Answers:` +- Numbered lines `N. : ` +- `checkboxes` comma-separated values +- `yes_no_text` as `yes — ""` when text exists +- optional empties as `(no answer)` + +Then dispatch: + +```ts +await this.dispatchSyntheticMessage({ channel, threadTs, userId, text: formattedAnswers }); +``` + +- [x] **Step 5: Run targeted Slack adapter tests** + +Run: +```bash +bun test packages/adapters/src/chat/slack/adapter.test.ts +``` + +Expected: PASS; includes new questions-button, modal-open, and modal-submit assertions. + +- [x] **Step 6: Commit modal interaction implementation** + +Run: +```bash +git add packages/adapters/src/chat/slack/adapter.ts packages/adapters/src/chat/slack/adapter.test.ts +git commit -m "feat(slack): collect spec scoping answers via question modal and synthesize loop reply" +``` + +--- + +### Task 4: Complete Test Coverage for Fallback + No Regression + +**Files:** +- Modify: `packages/adapters/src/chat/slack/adapter.test.ts` + +- [x] **Step 1: Add schema-valid render-path test** + +Add test: +- Input message contains prose + valid fenced `archon-questions`. +- `interactiveGate` present. +- Assert `postMessage.blocks` contains markdown + single actions block with `Answer questions`. +- Assert no Approve/Request changes buttons. +- Assert rendered markdown text excludes fenced YAML. + +- [x] **Step 2: Add malformed-schema fallback test** + +Add test: +- Input message contains malformed fenced block. +- `interactiveGate` present. +- Assert fallback actions are Approve + Request changes. +- Assert cleaned message does not include raw fenced block. + +- [x] **Step 3: Add no-schema regression test** + +Add test: +- Same message without fenced schema. +- Assert current gate behavior remains unchanged. + +- [x] **Step 4: Add modal submit formatting test** + +Mock `view_submission` payload for mixed question types and assert synthetic event text is exactly: + +```text +Answers: +1. scope_of_change: trial_activated, waiting_trial_webinar +2. test_expectations: yes — "update welcome_header_spec" +3. i18n: no +4. out_of_scope_confirm: yes +``` + +- [x] **Step 5: Run package tests** + +Run: +```bash +bun test packages/adapters/src/chat/slack/adapter.test.ts +``` + +Expected: PASS for all Slack adapter tests. + +- [x] **Step 6: Commit tests** + +Run: +```bash +git add packages/adapters/src/chat/slack/adapter.test.ts +git commit -m "test(slack): cover question-schema gate rendering, fallback, and modal answer formatting" +``` + +--- + +### Task 5: Final Validation + Manual Slack Check + +**Files:** +- Verify only modified files from prior tasks + +- [x] **Step 1: Run lint and type-check for touched packages** + +Run: +```bash +bun run lint +bun run type-check +``` + +Expected: PASS with zero warnings/errors. + +- [x] **Step 2: Run full pre-PR validation** + +Run: +```bash +bun run validate +``` + +Expected: PASS (type-check + lint + format check + tests). + +- [x] **Step 3: Manual Slack smoke test** + +Manual script: +1. Trigger `archon-slack-feature-to-review-app` with a sample feature request. +2. Confirm first `spec` iteration shows `Answer questions` button. +3. Submit modal and verify the next loop turn uses formatted `Answers:` text. +4. Confirm later approval gate still uses Approve / Request changes. + +Expected: end-to-end behavior matches spec acceptance criteria 1-5. + +- [x] **Step 4: Final commit (if any uncommitted validation fixes)** + +Run: +```bash +git add .archon/workflows/defaults/archon-slack-feature-to-review-app.yaml packages/adapters/src/chat/slack/adapter.ts packages/adapters/src/chat/slack/adapter.test.ts +git commit -m "feat(slack): add structured scoping-question modal for spec loop" +``` + +--- + +## Self-Review + +### 1) Spec coverage check +- Prompt schema contract: covered in Task 1. +- Slack button/modal flow: covered in Tasks 2-3. +- Answer formatting back to loop: covered in Task 3 + Task 4 formatting assertion. +- Malformed-schema fallback: covered in Task 4. +- No-regression behavior for existing gate: covered in Task 4. +- Validation/manual acceptance: covered in Task 5. + +No uncovered spec requirement found. + +### 2) Placeholder scan +- No TODO/TBD markers. +- Each code-changing task includes concrete function names and command steps. +- Test tasks include explicit assertions and expected outputs. + +### 3) Type consistency check +- Schema naming consistent: `QuestionDef`, `QuestionType`, `QuestionOption`. +- Action id constant consistent: `GATE_ACTION_ANSWER_QUESTIONS`. +- Modal callback consistent: `QUESTIONS_MODAL_CALLBACK`. +- Formatting function consistently named `formatQuestionsAnswersForLoop`. + +No naming or contract mismatches found. diff --git a/packages/adapters/package.json b/packages/adapters/package.json index 0e2fb23d52..7012156741 100644 --- a/packages/adapters/package.json +++ b/packages/adapters/package.json @@ -19,6 +19,7 @@ "@archon/git": "workspace:*", "@archon/isolation": "workspace:*", "@archon/paths": "workspace:*", + "js-yaml": "^4.1.0", "@octokit/rest": "^22.0.0", "@slack/bolt": "^4.6.0", "discord.js": "^14.16.0", diff --git a/packages/adapters/src/chat/slack/adapter.test.ts b/packages/adapters/src/chat/slack/adapter.test.ts index a1845cd1db..b46ae8c963 100644 --- a/packages/adapters/src/chat/slack/adapter.test.ts +++ b/packages/adapters/src/chat/slack/adapter.test.ts @@ -31,6 +31,9 @@ const mockEvent = mock(() => {}); const mockStart = mock(() => Promise.resolve(undefined)); const mockStop = mock(() => Promise.resolve(undefined)); +const mockAction = mock(() => {}); +const mockView = mock(() => {}); + const mockApp = { client: { chat: { @@ -44,6 +47,8 @@ const mockApp = { }, }, event: mockEvent, + action: mockAction, + view: mockView, start: mockStart, stop: mockStop, }; @@ -488,4 +493,274 @@ describe('SlackAdapter', () => { expect(mockReactionsAdd).toHaveBeenCalledTimes(1); }); }); + + describe('archon-questions schema rendering', () => { + const VALID_QUESTIONS_BLOCK = [ + '```archon-questions', + '- id: scope_of_change', + ' type: checkboxes', + ' label: "Which states should get the header?"', + ' options:', + ' - { value: trial_activated, label: "trial_activated" }', + ' - { value: waiting_trial_webinar, label: "waiting_trial_webinar" }', + ' required: true', + '- id: test_expectations', + ' type: yes_no_text', + ' label: "Are there existing specs to update?"', + ' open_text_label: "Known test expectations"', + '- id: i18n', + ' type: yes_no', + ' label: "Is this text subject to i18n?"', + '- id: out_of_scope_confirm', + ' type: yes_no', + ' label: "No other copy changes?"', + '```', + ].join('\n'); + + let adapter: SlackAdapter; + + beforeEach(() => { + adapter = new SlackAdapter('xoxb-fake', 'xapp-fake'); + mockPostMessage.mockClear(); + }); + + test('renders Answer questions button when valid archon-questions block is present with gate', async () => { + const message = `I have 4 scoping questions.\n\n${VALID_QUESTIONS_BLOCK}`; + await adapter.sendMessage('C123:1234.5678', message, { + interactiveGate: { runId: 'run-q1', nodeId: 'spec' }, + }); + + expect(mockPostMessage).toHaveBeenCalledTimes(1); + const call = (mockPostMessage as Mock).mock.calls[0][0] as { + blocks: unknown[]; + text: string; + }; + expect(call.blocks).toHaveLength(2); + + // Markdown block should NOT contain the fenced YAML + const mdBlock = call.blocks[0] as { type: string; text: string }; + expect(mdBlock.text).not.toContain('archon-questions'); + expect(mdBlock.text).toContain('I have 4 scoping questions.'); + + // Actions block should have a single "Answer questions" button + const actionsBlock = call.blocks[1] as { + type: string; + elements: Array<{ action_id: string; text: { text: string }; style?: string }>; + }; + expect(actionsBlock.type).toBe('actions'); + expect(actionsBlock.elements).toHaveLength(1); + expect(actionsBlock.elements[0].text.text).toBe('Answer questions'); + expect(actionsBlock.elements[0].style).toBe('primary'); + expect(actionsBlock.elements[0].action_id).toContain('gate_answer_questions'); + + // Fallback text should also be clean + expect(call.text).not.toContain('archon-questions'); + }); + + test('falls back to Approve/Request changes when schema is malformed', async () => { + const malformed = '```archon-questions\n- not: valid: yaml: [[\n```'; + const message = `Some intro.\n\n${malformed}`; + await adapter.sendMessage('C123:1234.5678', message, { + interactiveGate: { runId: 'run-bad', nodeId: 'spec' }, + }); + + expect(mockPostMessage).toHaveBeenCalledTimes(1); + const call = (mockPostMessage as Mock).mock.calls[0][0] as { + blocks: unknown[]; + text: string; + }; + // Should still have 2 blocks: markdown + actions + expect(call.blocks).toHaveLength(2); + + // Markdown should not contain the raw fenced block + const mdBlock = call.blocks[0] as { text: string }; + expect(mdBlock.text).not.toContain('archon-questions'); + + // Should fall back to approve/request changes + const actionsBlock = call.blocks[1] as { + elements: Array<{ action_id: string; text: { text: string } }>; + }; + expect(actionsBlock.elements).toHaveLength(2); + expect(actionsBlock.elements[0].text.text).toBe('Approve'); + expect(actionsBlock.elements[1].text.text).toBe('Request changes'); + }); + + test('no-schema message renders existing gate behavior unchanged', async () => { + await adapter.sendMessage('C123:1234.5678', 'Please review the spec.', { + interactiveGate: { runId: 'run-normal', nodeId: 'refine-plan' }, + }); + + const call = (mockPostMessage as Mock).mock.calls[0][0] as { + blocks: unknown[]; + }; + expect(call.blocks).toHaveLength(2); + const actionsBlock = call.blocks[1] as { + elements: Array<{ text: { text: string } }>; + }; + expect(actionsBlock.elements).toHaveLength(2); + expect(actionsBlock.elements[0].text.text).toBe('Approve'); + expect(actionsBlock.elements[1].text.text).toBe('Request changes'); + }); + + test('strips fenced block even without gate metadata', async () => { + const message = `Intro text.\n\n${VALID_QUESTIONS_BLOCK}\n\nEnd text.`; + await adapter.sendMessage('C123:1234.5678', message); + + const call = (mockPostMessage as Mock).mock.calls[0][0] as { + blocks: unknown[]; + }; + // Only markdown block, no actions (no gate) + expect(call.blocks).toHaveLength(1); + const mdBlock = call.blocks[0] as { text: string }; + expect(mdBlock.text).not.toContain('archon-questions'); + expect(mdBlock.text).toContain('Intro text.'); + expect(mdBlock.text).toContain('End text.'); + }); + + test('falls back when schema has unknown question type', async () => { + const unknownType = [ + '```archon-questions', + '- id: bad_q', + ' type: dropdown', + ' label: "Pick one"', + '```', + ].join('\n'); + await adapter.sendMessage('C123:1234.5678', unknownType, { + interactiveGate: { runId: 'run-unk', nodeId: 'spec' }, + }); + + const call = (mockPostMessage as Mock).mock.calls[0][0] as { + blocks: unknown[]; + }; + const actionsBlock = call.blocks[1] as { + elements: Array<{ text: { text: string } }>; + }; + expect(actionsBlock.elements[0].text.text).toBe('Approve'); + }); + + test('falls back when select type is missing options', async () => { + const noOptions = [ + '```archon-questions', + '- id: choose', + ' type: select', + ' label: "Pick"', + '```', + ].join('\n'); + await adapter.sendMessage('C123:1234.5678', noOptions, { + interactiveGate: { runId: 'run-no-opt', nodeId: 'spec' }, + }); + + const call = (mockPostMessage as Mock).mock.calls[0][0] as { + blocks: unknown[]; + }; + const actionsBlock = call.blocks[1] as { + elements: Array<{ text: { text: string } }>; + }; + expect(actionsBlock.elements[0].text.text).toBe('Approve'); + }); + }); + + describe('question answer formatting', () => { + test('formats mixed question types correctly', async () => { + // We test the formatting indirectly via the modal submit handler. + // To test formatting directly, we trigger the full flow via start() + + // simulated view submission. + const adapter = new SlackAdapter('xoxb-fake', 'xapp-fake'); + + // Capture the view handler callback registered during start() + let viewHandler: ((args: Record) => Promise) | undefined; + mockView.mockImplementation((( + callbackId: string, + handler: (args: Record) => Promise + ) => { + if (callbackId === 'gate_questions_modal') { + viewHandler = handler; + } + }) as typeof mockView); + + // Register a message handler so dispatchSyntheticMessage works + let capturedText = ''; + adapter.onMessage(async event => { + capturedText = event.text; + }); + + await adapter.start(); + expect(viewHandler).toBeDefined(); + + const questions = [ + { + id: 'scope_of_change', + type: 'checkboxes', + label: 'Scope', + options: [ + { value: 'trial_activated', label: 'trial_activated' }, + { value: 'waiting_trial_webinar', label: 'waiting_trial_webinar' }, + ], + }, + { + id: 'test_expectations', + type: 'yes_no_text', + label: 'Tests?', + open_text_label: 'Known tests', + }, + { id: 'i18n', type: 'yes_no', label: 'i18n?' }, + { id: 'out_of_scope_confirm', type: 'yes_no', label: 'Out of scope?' }, + ]; + + const privateMetadata = JSON.stringify({ + channel: 'C123', + threadTs: '1234.5678', + userId: 'U789', + questions, + }); + + await viewHandler!({ + ack: async () => {}, + view: { + private_metadata: privateMetadata, + state: { + values: { + scope_of_change: { + scope_of_change_input: { + selected_options: [ + { value: 'trial_activated' }, + { value: 'waiting_trial_webinar' }, + ], + }, + }, + test_expectations: { + test_expectations_input: { + selected_option: { value: 'yes' }, + }, + }, + test_expectations_text: { + test_expectations_text_input: { + value: 'update welcome_header_spec', + }, + }, + i18n: { + i18n_input: { + selected_option: { value: 'no' }, + }, + }, + out_of_scope_confirm: { + out_of_scope_confirm_input: { + selected_option: { value: 'yes' }, + }, + }, + }, + }, + }, + body: { user: { id: 'U789' } }, + }); + + expect(capturedText).toBe( + 'Answers:\n' + + '1. scope_of_change: trial_activated, waiting_trial_webinar\n' + + '2. test_expectations: yes \u2014 "update welcome_header_spec"\n' + + '3. i18n: no\n' + + '4. out_of_scope_confirm: yes' + ); + }); + }); }); diff --git a/packages/adapters/src/chat/slack/adapter.ts b/packages/adapters/src/chat/slack/adapter.ts index 4f697a615e..cef456f2b3 100644 --- a/packages/adapters/src/chat/slack/adapter.ts +++ b/packages/adapters/src/chat/slack/adapter.ts @@ -9,6 +9,7 @@ import { isSlackUserAuthorized } from './auth'; import { parseAllowedUserIds } from './auth'; import { splitIntoParagraphChunks } from '../../utils/message-splitting'; import type { SlackMessageEvent } from './types'; +import jsYaml from 'js-yaml'; /** * Gate action-id + modal callback-id encoding. runId and nodeId are packed @@ -19,7 +20,24 @@ import type { SlackMessageEvent } from './types'; const GATE_SEP = '|'; const GATE_ACTION_APPROVE = 'gate_approve'; const GATE_ACTION_REQUEST_CHANGES = 'gate_request_changes'; +const GATE_ACTION_ANSWER_QUESTIONS = 'gate_answer_questions'; const GATE_MODAL_CALLBACK = 'gate_changes_modal'; +const QUESTIONS_MODAL_CALLBACK = 'gate_questions_modal'; +const QUESTIONS_BLOCK_REGEX = /```archon-questions\n([\s\S]*?)```/m; + +type QuestionType = 'yes_no' | 'yes_no_text' | 'select' | 'checkboxes' | 'text'; +interface QuestionOption { + value: string; + label: string; +} +interface QuestionDef { + id: string; + type: QuestionType; + label: string; + required?: boolean; + options?: QuestionOption[]; + open_text_label?: string; +} function encodeGateActionId(prefix: string, runId: string, nodeId: string): string { return `${prefix}${GATE_SEP}${runId}${GATE_SEP}${nodeId}`; @@ -129,8 +147,11 @@ export class SlackAdapter implements IPlatformAdapter { threadTs?: string, gate?: { runId: string; nodeId: string } ): Promise { - const blocks: SlackBlock[] = [{ type: 'markdown', text: message }]; - if (gate) { + const { cleanedMessage, questions } = this.extractQuestionsBlock(message); + const blocks: SlackBlock[] = [{ type: 'markdown', text: cleanedMessage }]; + if (gate && questions) { + blocks.push(this.buildQuestionsActionsBlock(gate, questions)); + } else if (gate) { blocks.push(this.buildGateActionsBlock(gate)); } try { @@ -142,10 +163,14 @@ export class SlackAdapter implements IPlatformAdapter { thread_ts: threadTs, blocks, // Fallback text for notifications/accessibility - text: message.substring(0, 150) + (message.length > 150 ? '...' : ''), + text: cleanedMessage.substring(0, 150) + (cleanedMessage.length > 150 ? '...' : ''), } as unknown as Parameters[0]); getLog().debug( - { messageLength: message.length, gate: Boolean(gate) }, + { + messageLength: cleanedMessage.length, + gate: Boolean(gate), + hasQuestions: Boolean(questions), + }, 'slack.markdown_block_sent' ); } catch (error) { @@ -157,7 +182,7 @@ export class SlackAdapter implements IPlatformAdapter { await this.app.client.chat.postMessage({ channel, thread_ts: threadTs, - text: message, + text: cleanedMessage, }); } } @@ -189,6 +214,248 @@ export class SlackAdapter implements IPlatformAdapter { }; } + /** + * Extract and strip the `archon-questions` fenced block from a message. + * Returns the cleaned message (always stripped) and parsed questions (null if + * invalid or absent). + */ + private extractQuestionsBlock(message: string): { + cleanedMessage: string; + questions: QuestionDef[] | null; + } { + const match = QUESTIONS_BLOCK_REGEX.exec(message); + if (!match) return { cleanedMessage: message, questions: null }; + + const cleanedMessage = message.replace(QUESTIONS_BLOCK_REGEX, '').trim(); + const questions = this.parseQuestionsYaml(match[1]); + return { cleanedMessage, questions }; + } + + private parseQuestionsYaml(raw: string): QuestionDef[] | null { + try { + const parsed = jsYaml.load(raw); + if (!this.isValidQuestionDefArray(parsed)) return null; + return parsed; + } catch (e) { + const err = e as Error; + getLog().warn({ reason: err.message }, 'slack.questions_schema_invalid'); + return null; + } + } + + private isValidQuestionDefArray(value: unknown): value is QuestionDef[] { + if (!Array.isArray(value) || value.length === 0) { + getLog().warn({ reason: 'not a non-empty array' }, 'slack.questions_schema_invalid'); + return false; + } + const validTypes: QuestionType[] = ['yes_no', 'yes_no_text', 'select', 'checkboxes', 'text']; + for (const item of value) { + if (typeof item !== 'object' || item === null) { + getLog().warn({ reason: 'item is not an object' }, 'slack.questions_schema_invalid'); + return false; + } + const q = item as Record; + if (typeof q.id !== 'string' || typeof q.label !== 'string') { + getLog().warn({ reason: 'missing id or label' }, 'slack.questions_schema_invalid'); + return false; + } + if (!validTypes.includes(q.type as QuestionType)) { + getLog().warn( + { reason: `unknown type: ${String(q.type)}` }, + 'slack.questions_schema_invalid' + ); + return false; + } + if ((q.type === 'select' || q.type === 'checkboxes') && !Array.isArray(q.options)) { + getLog().warn({ reason: `${q.type} missing options` }, 'slack.questions_schema_invalid'); + return false; + } + } + return true; + } + + /** + * Build an actions block with a single "Answer questions" button. + * The questions array is encoded in the action value so the click handler + * can reconstruct the modal without DB state. + */ + private buildQuestionsActionsBlock( + gate: { runId: string; nodeId: string }, + questions: QuestionDef[] + ): SlackBlock { + return { + type: 'actions', + block_id: encodeGateActionId('gate_questions_block', gate.runId, gate.nodeId), + elements: [ + { + type: 'button', + action_id: encodeGateActionId(GATE_ACTION_ANSWER_QUESTIONS, gate.runId, gate.nodeId), + style: 'primary', + text: { type: 'plain_text', text: 'Answer questions', emoji: true }, + value: JSON.stringify(questions), + }, + ], + }; + } + + /** + * Build modal input blocks for all supported question types. + */ + private buildQuestionsModalBlocks(questions: QuestionDef[]): SlackBlock[] { + const blocks: SlackBlock[] = []; + for (const q of questions) { + const isRequired = q.required !== false; + switch (q.type) { + case 'yes_no': + blocks.push({ + type: 'input', + block_id: q.id, + optional: !isRequired, + label: { type: 'plain_text', text: q.label }, + element: { + type: 'radio_buttons', + action_id: `${q.id}_input`, + options: [ + { text: { type: 'plain_text', text: 'Yes' }, value: 'yes' }, + { text: { type: 'plain_text', text: 'No' }, value: 'no' }, + ], + }, + }); + break; + case 'yes_no_text': + blocks.push({ + type: 'input', + block_id: q.id, + optional: !isRequired, + label: { type: 'plain_text', text: q.label }, + element: { + type: 'radio_buttons', + action_id: `${q.id}_input`, + options: [ + { text: { type: 'plain_text', text: 'Yes' }, value: 'yes' }, + { text: { type: 'plain_text', text: 'No' }, value: 'no' }, + ], + }, + }); + blocks.push({ + type: 'input', + block_id: `${q.id}_text`, + optional: true, + label: { + type: 'plain_text', + text: q.open_text_label ?? 'Additional details (optional)', + }, + element: { + type: 'plain_text_input', + action_id: `${q.id}_text_input`, + multiline: true, + }, + }); + break; + case 'select': + blocks.push({ + type: 'input', + block_id: q.id, + optional: !isRequired, + label: { type: 'plain_text', text: q.label }, + element: { + type: 'static_select', + action_id: `${q.id}_input`, + options: (q.options ?? []).map(o => ({ + text: { type: 'plain_text', text: o.label }, + value: o.value, + })), + }, + }); + break; + case 'checkboxes': + blocks.push({ + type: 'input', + block_id: q.id, + optional: !isRequired, + label: { type: 'plain_text', text: q.label }, + element: { + type: 'checkboxes', + action_id: `${q.id}_input`, + options: (q.options ?? []).map(o => ({ + text: { type: 'plain_text', text: o.label }, + value: o.value, + })), + }, + }); + break; + case 'text': + blocks.push({ + type: 'input', + block_id: q.id, + optional: !isRequired, + label: { type: 'plain_text', text: q.label }, + element: { + type: 'plain_text_input', + action_id: `${q.id}_input`, + multiline: true, + }, + }); + break; + } + } + return blocks; + } + + /** + * Format modal submission values into deterministic text for `$LOOP_USER_INPUT`. + */ + private formatQuestionsAnswersForLoop( + questions: QuestionDef[], + values: Record< + string, + Record< + string, + { + value?: string | null; + selected_option?: { value?: string } | null; + selected_options?: { value?: string }[]; + } + > + > + ): string { + const lines: string[] = ['Answers:']; + for (let i = 0; i < questions.length; i++) { + const q = questions[i]; + const blockValues = values[q.id]; + const actionValues = blockValues?.[`${q.id}_input`]; + let answer: string; + + switch (q.type) { + case 'yes_no': + answer = actionValues?.selected_option?.value ?? '(no answer)'; + break; + case 'yes_no_text': { + const yn = actionValues?.selected_option?.value ?? '(no answer)'; + const textBlock = values[`${q.id}_text`]; + const openText = textBlock?.[`${q.id}_text_input`]?.value?.trim(); + answer = openText ? `${yn} \u2014 "${openText}"` : yn; + break; + } + case 'select': + answer = actionValues?.selected_option?.value ?? '(no answer)'; + break; + case 'checkboxes': { + const selected = actionValues?.selected_options?.map(o => o.value).filter(Boolean) ?? []; + answer = selected.length > 0 ? selected.join(', ') : '(no answer)'; + break; + } + case 'text': + answer = actionValues?.value?.trim() || '(no answer)'; + break; + default: + answer = '(no answer)'; + } + lines.push(`${i + 1}. ${q.id}: ${answer}`); + } + return lines.join('\n'); + } + /** * Get the Bolt App instance */ @@ -463,11 +730,26 @@ export class SlackAdapter implements IPlatformAdapter { } ); + // Answer questions button — opens a modal with typed inputs. + this.app.action( + { type: 'block_actions', action_id: new RegExp(`^${GATE_ACTION_ANSWER_QUESTIONS}\\|`) }, + async ({ ack, body, action, client }) => { + await ack(); + await this.handleAnswerQuestionsClick({ body, action, client }); + } + ); + // Modal submission — feedback text is synthesized as a thread message. this.app.view(GATE_MODAL_CALLBACK, async ({ ack, view, body }) => { await ack(); await this.handleGateModalSubmit({ view, body }); }); + + // Questions modal submission — answers formatted and synthesized as thread message. + this.app.view(QUESTIONS_MODAL_CALLBACK, async ({ ack, view, body }) => { + await ack(); + await this.handleQuestionsModalSubmit({ view, body }); + }); } /** @@ -632,6 +914,120 @@ export class SlackAdapter implements IPlatformAdapter { }); } + /** + * Handle "Answer questions" click: open a modal with typed inputs built + * from the question schema stored in the button's value. + */ + private async handleAnswerQuestionsClick(params: { + body: unknown; + action: unknown; + client: unknown; + }): Promise { + const { body, action, client } = params; + const ctx = this.extractClickContext(body, action); + const triggerId = this.extractTriggerId(body); + const ids = decodeGateActionId((action as { action_id?: string }).action_id); + if (!ctx || !triggerId) { + getLog().warn({ ids }, 'slack.questions_click_missing_context'); + return; + } + + let questions: QuestionDef[]; + try { + questions = JSON.parse((action as { value?: string }).value ?? '[]') as QuestionDef[]; + } catch { + getLog().warn({ ids }, 'slack.questions_click_bad_value'); + return; + } + + getLog().info( + { + runId: ids?.runId, + nodeId: ids?.nodeId, + userId: ctx.userId, + questionCount: questions.length, + }, + 'slack.questions_modal_opening' + ); + + const privateMetadata = JSON.stringify({ + channel: ctx.channel, + threadTs: ctx.threadTs, + userId: ctx.userId, + questions, + }); + + const webClient = client as { + views: { open: (args: Record) => Promise }; + }; + try { + await webClient.views.open({ + trigger_id: triggerId, + view: { + type: 'modal', + callback_id: QUESTIONS_MODAL_CALLBACK, + private_metadata: privateMetadata, + title: { type: 'plain_text', text: 'Scoping questions' }, + submit: { type: 'plain_text', text: 'Submit' }, + close: { type: 'plain_text', text: 'Cancel' }, + blocks: this.buildQuestionsModalBlocks(questions), + }, + }); + } catch (error) { + getLog().error({ err: error }, 'slack.questions_modal_open_failed'); + } + } + + /** + * Handle questions modal submission: format answers and synthesize a thread + * message so the workflow loop receives structured input. + */ + private async handleQuestionsModalSubmit(params: { + view: unknown; + body: unknown; + }): Promise { + const { view, body } = params; + const v = view as { + private_metadata?: string; + state?: { + values?: Record< + string, + Record< + string, + { + value?: string | null; + selected_option?: { value?: string } | null; + selected_options?: { value?: string }[]; + } + > + >; + }; + }; + + let meta: { channel?: string; threadTs?: string; userId?: string; questions?: QuestionDef[] } = + {}; + try { + meta = v.private_metadata ? (JSON.parse(v.private_metadata) as typeof meta) : {}; + } catch { + getLog().warn('slack.questions_modal_bad_private_metadata'); + return; + } + if (!meta.channel || !meta.threadTs || !meta.questions || !v.state?.values) { + getLog().warn('slack.questions_modal_missing_fields'); + return; + } + + const userId = (body as { user?: { id?: string } }).user?.id ?? meta.userId ?? 'unknown'; + const formattedAnswers = this.formatQuestionsAnswersForLoop(meta.questions, v.state.values); + + await this.dispatchSyntheticMessage({ + channel: meta.channel, + threadTs: meta.threadTs, + userId, + text: formattedAnswers, + }); + } + /** * Extract channel, message ts, thread, and user from a block_actions body. * Returns null if any required field is missing (shouldn't happen in