diff --git a/.archon/commands/defaults/scout-consolidate-perf-plan.md b/.archon/commands/defaults/scout-consolidate-perf-plan.md new file mode 100644 index 0000000000..7d5f12e9b3 --- /dev/null +++ b/.archon/commands/defaults/scout-consolidate-perf-plan.md @@ -0,0 +1,96 @@ +--- +description: Merge per-route Scout profiles into a single implementation plan (plan.md) +argument-hint: (no arguments) +--- + +# Consolidate Scout performance plan + +**Workflow ID**: $WORKFLOW_ID +**Artifacts**: $ARTIFACTS_DIR + +--- + +## Mission + +Read: + +- `$ARTIFACTS_DIR/routes.json` +- `$ARTIFACTS_DIR/routes-summary.md` +- `$ARTIFACTS_DIR/profile-00.md` … `$ARTIFACTS_DIR/profile-09.md` (include only files that exist; skipped indices may say SKIPPED) + +Produce **one** implementation plan at **`$ARTIFACTS_DIR/plan.md`** that `archon-plan-setup` / `archon-confirm-plan` / `archon-implement-tasks` can consume. + +--- + +## Plan template (required sections) + +Use this structure (fill with real content from profiles): + +```markdown +# Performance: Scout hot-route optimizations + +## Summary +{1–2 sentences} + +## Mission +{Single goal statement} + +## NOT Building (Scope Limits) +- {Explicit non-goals — e.g. unrelated refactors, new features} +- Do not change behavior except latency/resource usage unless noted. + +## Success Criteria +- [ ] Each targeted route has measurable improvement or documented tradeoff +- [ ] Project validation suite passes (see CLAUDE.md) +- [ ] Scout shows no new regressions for these endpoints after deploy (verification note) + +## Files to Change + +| File | Action | +|------|--------| +| `{path}` | UPDATE | + +## Patterns to Mirror + +| Pattern | Source File | Lines | +|---------|-------------|-------| +| {name} | `{path}` | {lines} | + +## Task List + +### Task 1: {title} +**Action**: UPDATE +**Details**: {specific changes} +**Route**: `{METHOD} {path}` +**Validate**: {command} + +### Task 2: ... + +(Add one or more tasks per route or grouped fix.) + +## Validation Commands +1. Commands from CLAUDE.md / package.json (typecheck, lint, test). + +## Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| {risk} | {H/M/L} | {mitigation} | +``` + +--- + +## Rules + +1. **Deduplicate** overlapping tasks if multiple profiles touch the same file. +2. **Order** tasks by dependency (models before handlers, shared utils first). +3. **Reference** actual symbols/files from the profile markdown files. +4. If profiles disagree, prefer the most evidence-backed recommendation and note the conflict in **Risks**. +5. Ignore profiles that are SKIPPED or empty. + +--- + +## Output + +- Write **`$ARTIFACTS_DIR/plan.md`** only (plan-setup will create `plan-context.md`). +- Stdout: `Plan written to $ARTIFACTS_DIR/plan.md with {N} tasks.` diff --git a/.archon/commands/defaults/scout-discover-routes.md b/.archon/commands/defaults/scout-discover-routes.md new file mode 100644 index 0000000000..d6a11ee320 --- /dev/null +++ b/.archon/commands/defaults/scout-discover-routes.md @@ -0,0 +1,67 @@ +--- +description: Query Scout APM for top slow + high-traffic routes and write routes.json +argument-hint: "[optional app name or app id — else first active app]" +--- + +# Scout route discovery + +**User message**: $ARGUMENTS +**Artifacts**: $ARTIFACTS_DIR + +--- + +## Mission + +Use **Scout MCP** tools to identify **up to 10 HTTP routes** to optimize. Combine: + +- **Slowest** endpoints (p95 / mean response time), and +- **Most hit** (throughput / request volume) + +Dedupe by route identity (method + path). If you have fewer than 10 distinct hot/slow routes, include the next candidates by severity. If Scout returns fewer than 10, use all available. + +--- + +## Prerequisites + +1. **MCP**: Scout tools should be available (`list_apps`, `get_app_endpoints`, `get_endpoint_metrics`, etc.). If MCP is unavailable, ask the user to set `SCOUT_API_KEY`, ensure Docker can run `scoutapp/scout-mcp-local`, or paste a Scout endpoints export into `$ARTIFACTS_DIR/scout-endpoints-export.json` (array of endpoint objects) and continue from that file. + +2. **App selection**: If `$ARGUMENTS` names an app or numeric id, use that. Otherwise call `list_apps` and pick the production app that matches this repo (name/hostname) or the most recently active app. State which app you chose. + +--- + +## Steps + +1. Call Scout MCP to list endpoints with metrics for the chosen app (`get_app_endpoints` or equivalent). + +2. Rank and select up to **10** routes using a clear rule, e.g.: + - Take top **5** by p95 latency (or worst mean response time if p95 missing). + - Take top **5** by throughput. + - Union, dedupe, then fill remaining slots by composite score: `latency × log(throughput)` or similar. + +3. Write **`$ARTIFACTS_DIR/routes.json`** — JSON array of exactly the chosen routes, each object including at least: + + - `rank` (1–10) + - `method` (e.g. `GET`) + - `path` (e.g. `/api/foo`) + - `scout_name` or endpoint id if the API exposes one + - `p95_ms`, `mean_ms` (numbers or null) + - `rpm` or throughput (number or null) + - `error_rate` if available + +4. Write **`$ARTIFACTS_DIR/routes-summary.md`** — human-readable table: rank, method, path, p95, throughput, notes. + +5. Print a one-line stdout summary: `Discovered N routes for app {name} (id {id}).` + +--- + +## Error handling + +- If no endpoints are returned: write `routes.json` as `[]`, explain in summary, and STOP with a clear error in stdout so the workflow can fail visibly. + +--- + +## Success criteria + +- `routes.json` exists and is valid JSON. +- `routes-summary.md` exists. +- At most 10 routes; each profile step can rely on fixed indices `0..N-1`. diff --git a/.archon/config.yaml b/.archon/config.yaml index beadf10287..fc7d70da71 100644 --- a/.archon/config.yaml +++ b/.archon/config.yaml @@ -1,5 +1,8 @@ worktree: baseBranch: dev + # Copy local .env into isolated worktrees so Scout MCP sees SCOUT_API_KEY when cwd is ~/.archon/workspaces/... + copyFiles: + - .env docs: path: packages/docs-web/src/content/docs diff --git a/.archon/scripts/ci-wait.js b/.archon/scripts/ci-wait.js new file mode 100644 index 0000000000..51907c19cb --- /dev/null +++ b/.archon/scripts/ci-wait.js @@ -0,0 +1,70 @@ +#!/usr/bin/env bun +/** + * Wait for GitHub CI on a PR to finish, with a hard wall-clock timeout. + * + * Usage: bun .archon/scripts/ci-wait.js [timeout-ms] + * + * Exit codes: + * 0 — all required checks passed + * 1 — at least one required check failed + * 3 — timeout reached before CI finished + * 2 — bad args / missing gh + * + * Used by archon-slack-feature-to-review-app to gate review-app deploy. + */ +import { spawn } from 'node:child_process'; + +const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000; + +function main() { + const [pr, timeoutArg] = process.argv.slice(2); + + if (!pr) { + console.error('Usage: ci-wait.js [timeout-ms]'); + process.exit(2); + } + + const timeoutMs = timeoutArg ? Number(timeoutArg) : DEFAULT_TIMEOUT_MS; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + console.error(`Invalid timeout-ms: ${timeoutArg}`); + process.exit(2); + } + + console.log( + `Waiting for CI on PR ${pr} (timeout: ${Math.round(timeoutMs / 1000)}s)...` + ); + + const child = spawn( + 'gh', + ['pr', 'checks', pr, '--watch', '--fail-fast', '--interval', '30'], + { stdio: 'inherit' } + ); + + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + console.error(`\nCI wait timed out after ${Math.round(timeoutMs / 1000)}s`); + child.kill('SIGTERM'); + setTimeout(() => process.exit(3), 2000).unref(); + }, timeoutMs); + timer.unref(); + + child.on('exit', (code, _signal) => { + clearTimeout(timer); + if (timedOut) return; + if (code === 0) { + console.log('CI passed.'); + process.exit(0); + } + console.error(`CI failed (gh exit code ${code ?? 'null'})`); + process.exit(1); + }); + + child.on('error', err => { + clearTimeout(timer); + console.error(`Failed to spawn gh: ${err.message}`); + process.exit(2); + }); +} + +main(); diff --git a/.archon/scripts/dispatch-review-app.js b/.archon/scripts/dispatch-review-app.js new file mode 100644 index 0000000000..b7abfbccc0 --- /dev/null +++ b/.archon/scripts/dispatch-review-app.js @@ -0,0 +1,47 @@ +#!/usr/bin/env bun +/** + * Dispatch a GitHub Actions workflow_dispatch event on the given ref. + * + * Usage: bun .archon/scripts/dispatch-review-app.js + * + * Exits 0 on successful dispatch. Exits non-zero with a human-readable stderr + * message on any failure (missing args, gh not installed, gh call failed). + * + * Used by the archon-slack-feature-to-review-app workflow after CI passes + * to deploy a review app for the PR branch. + */ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +async function main() { + const [workflowFile, ref] = process.argv.slice(2); + + if (!workflowFile || !ref) { + console.error('Usage: dispatch-review-app.js '); + process.exit(2); + } + + try { + const { stdout, stderr } = await execFileAsync('gh', [ + 'workflow', + 'run', + workflowFile, + '--ref', + ref, + ]); + if (stdout.trim()) console.log(stdout.trim()); + if (stderr.trim()) console.log(stderr.trim()); + console.log( + JSON.stringify({ dispatched: true, workflow: workflowFile, ref }) + ); + } catch (err) { + console.error( + `Failed to dispatch ${workflowFile} on ref ${ref}: ${err.stderr ?? err.message}` + ); + process.exit(1); + } +} + +void main(); diff --git a/.archon/scripts/fetch-review-app-url.js b/.archon/scripts/fetch-review-app-url.js new file mode 100644 index 0000000000..24b2157c21 --- /dev/null +++ b/.archon/scripts/fetch-review-app-url.js @@ -0,0 +1,107 @@ +#!/usr/bin/env bun +/** + * Poll a GitHub PR's comments for a review-app URL matching a regex. + * + * Usage: + * bun .archon/scripts/fetch-review-app-url.js [timeout-ms] [interval-ms] + * + * Exit codes: + * 0 — URL found; printed to stdout as the only stdout line + * 3 — timeout reached without a match + * 2 — bad args / gh failure / invalid regex / bad comments JSON + * + * The workflow consumes the trimmed stdout via $.output. + * All log lines go to stderr so the URL is the only stdout content. + */ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; +const DEFAULT_INTERVAL_MS = 20 * 1000; + +async function pollOnce(pr, regex) { + const { stdout } = await execFileAsync('gh', [ + 'pr', + 'view', + pr, + '--json', + 'comments', + ]); + let parsed; + try { + parsed = JSON.parse(stdout); + } catch { + throw new Error(`gh returned non-JSON stdout: ${stdout.slice(0, 200)}`); + } + const comments = parsed.comments ?? []; + for (const c of comments) { + const match = typeof c.body === 'string' ? c.body.match(regex) : null; + if (match) return match[0]; + } + return null; +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function main() { + const [pr, regexStr, timeoutArg, intervalArg] = process.argv.slice(2); + + if (!pr || !regexStr) { + console.error( + 'Usage: fetch-review-app-url.js [timeout-ms] [interval-ms]' + ); + process.exit(2); + } + + let regex; + try { + regex = new RegExp(regexStr); + } catch (err) { + console.error( + `Invalid regex ${JSON.stringify(regexStr)}: ${err.message}` + ); + process.exit(2); + } + + const timeoutMs = timeoutArg ? Number(timeoutArg) : DEFAULT_TIMEOUT_MS; + const intervalMs = intervalArg ? Number(intervalArg) : DEFAULT_INTERVAL_MS; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + console.error(`Invalid timeout-ms: ${timeoutArg}`); + process.exit(2); + } + if (!Number.isFinite(intervalMs) || intervalMs <= 0) { + console.error(`Invalid interval-ms: ${intervalArg}`); + process.exit(2); + } + + const deadline = Date.now() + timeoutMs; + console.error( + `Polling PR ${pr} for pattern ${regex} every ${Math.round(intervalMs / 1000)}s, up to ${Math.round(timeoutMs / 1000)}s total...` + ); + + while (Date.now() < deadline) { + try { + const match = await pollOnce(pr, regex); + if (match) { + console.log(match); + return; + } + } catch (err) { + console.error(`Poll error (will retry): ${err.message}`); + } + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + await sleep(Math.min(intervalMs, remaining)); + } + + console.error( + `No matching comment found on PR ${pr} within ${Math.round(timeoutMs / 1000)}s.` + ); + process.exit(3); +} + +void main(); diff --git a/.archon/workflows/defaults/archon-scout-perf-roadmap.yaml b/.archon/workflows/defaults/archon-scout-perf-roadmap.yaml new file mode 100644 index 0000000000..e5b561ea81 --- /dev/null +++ b/.archon/workflows/defaults/archon-scout-perf-roadmap.yaml @@ -0,0 +1,488 @@ +name: archon-scout-perf-roadmap +description: | + Use when: Optimizing app performance using Scout APM. + Does: + 1. Scout MCP — top 10 slow + high-traffic HTTP routes + 2. Parallel analysis (10 nodes) — profile each route in the codebase + traces + 3. Consolidated optimization plan + 4. Interactive plan review until user approves + 5. Implement via plan pipeline (draft PR) + 6. Multi-agent code review + self-fix + 7. Mark PR ready when review satisfied + + Requires: SCOUT_API_KEY (self-hosted Docker MCP) or Scout endpoints export; Docker for scout-mcp-local. + Input: Optional app name/id after workflow message; e.g. "My App Name" or leave default. + + NOT for: Performance work without Scout (use archon-assist) or non-HTTP routes only. + +provider: claude +model: sonnet +interactive: true + +nodes: + # ═══════════════════════════════════════════════════════════════ + # PHASE 1 — Scout discovery + # ═══════════════════════════════════════════════════════════════ + + - id: discover-routes + command: scout-discover-routes + context: fresh + mcp: .archon/mcp/scout-apm.json + + - id: profile-00 + prompt: | + You are **one of ten parallel profilers** for the Scout performance roadmap. + + **Assigned index**: 0 (0-based). **Profile file**: `$ARTIFACTS_DIR/profile-00.md` + + ## Steps + + 1. Read `$ARTIFACTS_DIR/routes.json`. If there is **no** element at index 0, write to `$ARTIFACTS_DIR/profile-00.md` a single line `SKIPPED: no route at index 0` and stop. + 2. Otherwise take the route object at index 0. Map it to this codebase (routers, handlers, middleware, DB). + 3. Use **Scout MCP** when available: `get_endpoint_metrics`, `get_app_endpoint_traces`, `get_app_insights` for this app/endpoint to explain **where** time is spent (N+1, slow queries, external I/O, etc.). + 4. Read CLAUDE.md and search the codebase for the implementation path. + 5. Write **`$ARTIFACTS_DIR/profile-00.md`** with: + - Route identity (method + path) + - Scout metrics summary + - Code path (files + symbols) + - Root causes of slowness + - Concrete optimization ideas (caching, batching, indexes, async, etc.) + + Keep stdout to a short summary line for logs. + depends_on: [discover-routes] + context: fresh + mcp: .archon/mcp/scout-apm.json + + - id: profile-01 + prompt: | + You are **one of ten parallel profilers** for the Scout performance roadmap. + + **Assigned index**: 1 (0-based). **Profile file**: `$ARTIFACTS_DIR/profile-01.md` + + ## Steps + + 1. Read `$ARTIFACTS_DIR/routes.json`. If there is **no** element at index 1, write to `$ARTIFACTS_DIR/profile-01.md` a single line `SKIPPED: no route at index 1` and stop. + 2. Otherwise take the route object at index 1. Map it to this codebase (routers, handlers, middleware, DB). + 3. Use **Scout MCP** when available: `get_endpoint_metrics`, `get_app_endpoint_traces`, `get_app_insights` for this app/endpoint to explain **where** time is spent (N+1, slow queries, external I/O, etc.). + 4. Read CLAUDE.md and search the codebase for the implementation path. + 5. Write **`$ARTIFACTS_DIR/profile-01.md`** with: + - Route identity (method + path) + - Scout metrics summary + - Code path (files + symbols) + - Root causes of slowness + - Concrete optimization ideas (caching, batching, indexes, async, etc.) + + Keep stdout to a short summary line for logs. + depends_on: [discover-routes] + context: fresh + mcp: .archon/mcp/scout-apm.json + + - id: profile-02 + prompt: | + You are **one of ten parallel profilers** for the Scout performance roadmap. + + **Assigned index**: 2 (0-based). **Profile file**: `$ARTIFACTS_DIR/profile-02.md` + + ## Steps + + 1. Read `$ARTIFACTS_DIR/routes.json`. If there is **no** element at index 2, write to `$ARTIFACTS_DIR/profile-02.md` a single line `SKIPPED: no route at index 2` and stop. + 2. Otherwise take the route object at index 2. Map it to this codebase (routers, handlers, middleware, DB). + 3. Use **Scout MCP** when available: `get_endpoint_metrics`, `get_app_endpoint_traces`, `get_app_insights` for this app/endpoint to explain **where** time is spent (N+1, slow queries, external I/O, etc.). + 4. Read CLAUDE.md and search the codebase for the implementation path. + 5. Write **`$ARTIFACTS_DIR/profile-02.md`** with: + - Route identity (method + path) + - Scout metrics summary + - Code path (files + symbols) + - Root causes of slowness + - Concrete optimization ideas (caching, batching, indexes, async, etc.) + + Keep stdout to a short summary line for logs. + depends_on: [discover-routes] + context: fresh + mcp: .archon/mcp/scout-apm.json + + - id: profile-03 + prompt: | + You are **one of ten parallel profilers** for the Scout performance roadmap. + + **Assigned index**: 3 (0-based). **Profile file**: `$ARTIFACTS_DIR/profile-03.md` + + ## Steps + + 1. Read `$ARTIFACTS_DIR/routes.json`. If there is **no** element at index 3, write to `$ARTIFACTS_DIR/profile-03.md` a single line `SKIPPED: no route at index 3` and stop. + 2. Otherwise take the route object at index 3. Map it to this codebase (routers, handlers, middleware, DB). + 3. Use **Scout MCP** when available: `get_endpoint_metrics`, `get_app_endpoint_traces`, `get_app_insights` for this app/endpoint to explain **where** time is spent (N+1, slow queries, external I/O, etc.). + 4. Read CLAUDE.md and search the codebase for the implementation path. + 5. Write **`$ARTIFACTS_DIR/profile-03.md`** with: + - Route identity (method + path) + - Scout metrics summary + - Code path (files + symbols) + - Root causes of slowness + - Concrete optimization ideas (caching, batching, indexes, async, etc.) + + Keep stdout to a short summary line for logs. + depends_on: [discover-routes] + context: fresh + mcp: .archon/mcp/scout-apm.json + + - id: profile-04 + prompt: | + You are **one of ten parallel profilers** for the Scout performance roadmap. + + **Assigned index**: 4 (0-based). **Profile file**: `$ARTIFACTS_DIR/profile-04.md` + + ## Steps + + 1. Read `$ARTIFACTS_DIR/routes.json`. If there is **no** element at index 4, write to `$ARTIFACTS_DIR/profile-04.md` a single line `SKIPPED: no route at index 4` and stop. + 2. Otherwise take the route object at index 4. Map it to this codebase (routers, handlers, middleware, DB). + 3. Use **Scout MCP** when available: `get_endpoint_metrics`, `get_app_endpoint_traces`, `get_app_insights` for this app/endpoint to explain **where** time is spent (N+1, slow queries, external I/O, etc.). + 4. Read CLAUDE.md and search the codebase for the implementation path. + 5. Write **`$ARTIFACTS_DIR/profile-04.md`** with: + - Route identity (method + path) + - Scout metrics summary + - Code path (files + symbols) + - Root causes of slowness + - Concrete optimization ideas (caching, batching, indexes, async, etc.) + + Keep stdout to a short summary line for logs. + depends_on: [discover-routes] + context: fresh + mcp: .archon/mcp/scout-apm.json + + - id: profile-05 + prompt: | + You are **one of ten parallel profilers** for the Scout performance roadmap. + + **Assigned index**: 5 (0-based). **Profile file**: `$ARTIFACTS_DIR/profile-05.md` + + ## Steps + + 1. Read `$ARTIFACTS_DIR/routes.json`. If there is **no** element at index 5, write to `$ARTIFACTS_DIR/profile-05.md` a single line `SKIPPED: no route at index 5` and stop. + 2. Otherwise take the route object at index 5. Map it to this codebase (routers, handlers, middleware, DB). + 3. Use **Scout MCP** when available: `get_endpoint_metrics`, `get_app_endpoint_traces`, `get_app_insights` for this app/endpoint to explain **where** time is spent (N+1, slow queries, external I/O, etc.). + 4. Read CLAUDE.md and search the codebase for the implementation path. + 5. Write **`$ARTIFACTS_DIR/profile-05.md`** with: + - Route identity (method + path) + - Scout metrics summary + - Code path (files + symbols) + - Root causes of slowness + - Concrete optimization ideas (caching, batching, indexes, async, etc.) + + Keep stdout to a short summary line for logs. + depends_on: [discover-routes] + context: fresh + mcp: .archon/mcp/scout-apm.json + + - id: profile-06 + prompt: | + You are **one of ten parallel profilers** for the Scout performance roadmap. + + **Assigned index**: 6 (0-based). **Profile file**: `$ARTIFACTS_DIR/profile-06.md` + + ## Steps + + 1. Read `$ARTIFACTS_DIR/routes.json`. If there is **no** element at index 6, write to `$ARTIFACTS_DIR/profile-06.md` a single line `SKIPPED: no route at index 6` and stop. + 2. Otherwise take the route object at index 6. Map it to this codebase (routers, handlers, middleware, DB). + 3. Use **Scout MCP** when available: `get_endpoint_metrics`, `get_app_endpoint_traces`, `get_app_insights` for this app/endpoint to explain **where** time is spent (N+1, slow queries, external I/O, etc.). + 4. Read CLAUDE.md and search the codebase for the implementation path. + 5. Write **`$ARTIFACTS_DIR/profile-06.md`** with: + - Route identity (method + path) + - Scout metrics summary + - Code path (files + symbols) + - Root causes of slowness + - Concrete optimization ideas (caching, batching, indexes, async, etc.) + + Keep stdout to a short summary line for logs. + depends_on: [discover-routes] + context: fresh + mcp: .archon/mcp/scout-apm.json + + - id: profile-07 + prompt: | + You are **one of ten parallel profilers** for the Scout performance roadmap. + + **Assigned index**: 7 (0-based). **Profile file**: `$ARTIFACTS_DIR/profile-07.md` + + ## Steps + + 1. Read `$ARTIFACTS_DIR/routes.json`. If there is **no** element at index 7, write to `$ARTIFACTS_DIR/profile-07.md` a single line `SKIPPED: no route at index 7` and stop. + 2. Otherwise take the route object at index 7. Map it to this codebase (routers, handlers, middleware, DB). + 3. Use **Scout MCP** when available: `get_endpoint_metrics`, `get_app_endpoint_traces`, `get_app_insights` for this app/endpoint to explain **where** time is spent (N+1, slow queries, external I/O, etc.). + 4. Read CLAUDE.md and search the codebase for the implementation path. + 5. Write **`$ARTIFACTS_DIR/profile-07.md`** with: + - Route identity (method + path) + - Scout metrics summary + - Code path (files + symbols) + - Root causes of slowness + - Concrete optimization ideas (caching, batching, indexes, async, etc.) + + Keep stdout to a short summary line for logs. + depends_on: [discover-routes] + context: fresh + mcp: .archon/mcp/scout-apm.json + + - id: profile-08 + prompt: | + You are **one of ten parallel profilers** for the Scout performance roadmap. + + **Assigned index**: 8 (0-based). **Profile file**: `$ARTIFACTS_DIR/profile-08.md` + + ## Steps + + 1. Read `$ARTIFACTS_DIR/routes.json`. If there is **no** element at index 8, write to `$ARTIFACTS_DIR/profile-08.md` a single line `SKIPPED: no route at index 8` and stop. + 2. Otherwise take the route object at index 8. Map it to this codebase (routers, handlers, middleware, DB). + 3. Use **Scout MCP** when available: `get_endpoint_metrics`, `get_app_endpoint_traces`, `get_app_insights` for this app/endpoint to explain **where** time is spent (N+1, slow queries, external I/O, etc.). + 4. Read CLAUDE.md and search the codebase for the implementation path. + 5. Write **`$ARTIFACTS_DIR/profile-08.md`** with: + - Route identity (method + path) + - Scout metrics summary + - Code path (files + symbols) + - Root causes of slowness + - Concrete optimization ideas (caching, batching, indexes, async, etc.) + + Keep stdout to a short summary line for logs. + depends_on: [discover-routes] + context: fresh + mcp: .archon/mcp/scout-apm.json + + - id: profile-09 + prompt: | + You are **one of ten parallel profilers** for the Scout performance roadmap. + + **Assigned index**: 9 (0-based). **Profile file**: `$ARTIFACTS_DIR/profile-09.md` + + ## Steps + + 1. Read `$ARTIFACTS_DIR/routes.json`. If there is **no** element at index 9, write to `$ARTIFACTS_DIR/profile-09.md` a single line `SKIPPED: no route at index 9` and stop. + 2. Otherwise take the route object at index 9. Map it to this codebase (routers, handlers, middleware, DB). + 3. Use **Scout MCP** when available: `get_endpoint_metrics`, `get_app_endpoint_traces`, `get_app_insights` for this app/endpoint to explain **where** time is spent (N+1, slow queries, external I/O, etc.). + 4. Read CLAUDE.md and search the codebase for the implementation path. + 5. Write **`$ARTIFACTS_DIR/profile-09.md`** with: + - Route identity (method + path) + - Scout metrics summary + - Code path (files + symbols) + - Root causes of slowness + - Concrete optimization ideas (caching, batching, indexes, async, etc.) + + Keep stdout to a short summary line for logs. + depends_on: [discover-routes] + context: fresh + mcp: .archon/mcp/scout-apm.json + + # ═══════════════════════════════════════════════════════════════ + # PHASE 2 — Single plan from profiles + # ═══════════════════════════════════════════════════════════════ + + - id: consolidate-plan + command: scout-consolidate-perf-plan + depends_on: [profile-00, profile-01, profile-02, profile-03, profile-04, profile-05, profile-06, profile-07, profile-08, profile-09] + context: fresh + + # ═══════════════════════════════════════════════════════════════ + # PHASE 3 — Interactive plan review (human edits) + # ═══════════════════════════════════════════════════════════════ + + - id: refine-plan + depends_on: [consolidate-plan] + loop: + prompt: | + # Scout perf roadmap — Plan review + + The consolidated plan lives at `$ARTIFACTS_DIR/plan.md`. + + **User's latest message**: $LOOP_USER_INPUT + + --- + + ## If the first iteration (no user input yet): + + 1. Read `$ARTIFACTS_DIR/plan.md` and `$ARTIFACTS_DIR/routes-summary.md`. + 2. Present a clear summary: goals, tasks, risks, and what will change. + 3. Ask for feedback (scope, ordering, risk tradeoffs). + + **Do not** emit `PLAN_APPROVED` on the first iteration. + + ## If the user gave feedback: + + 1. Edit `$ARTIFACTS_DIR/plan.md` to incorporate changes (tasks, scope, exclusions). + 2. Show what changed. + 3. Ask for more feedback or approval. + + ## If the user gave **explicit approval** (e.g. "approved", "looks good", "implement", "ship it"): + + Output: "Plan approved. Proceeding to implementation setup." + Then emit: `PLAN_APPROVED` + + **CRITICAL**: Never emit `PLAN_APPROVED` unless the latest user message clearly approves. Questions are not approval. + until: PLAN_APPROVED + max_iterations: 15 + interactive: true + gate_message: | + Review the plan. Request changes, or say "approved" to implement (draft PR next). + + # ═══════════════════════════════════════════════════════════════ + # PHASE 4 — Plan execution pipeline (same as plan-to-pr) + # ═══════════════════════════════════════════════════════════════ + + - id: plan-setup + command: archon-plan-setup + depends_on: [refine-plan] + context: fresh + + - id: confirm-plan + command: archon-confirm-plan + depends_on: [plan-setup] + context: fresh + + - id: implement-tasks + command: archon-implement-tasks + depends_on: [confirm-plan] + context: fresh + model: claude-opus-4-6[1m] + + - id: validate + command: archon-validate + depends_on: [implement-tasks] + context: fresh + + # ═══════════════════════════════════════════════════════════════ + # PHASE 5 — Draft PR (not ready for review yet) + # ═══════════════════════════════════════════════════════════════ + + - id: create-draft-pr + prompt: | + Create a **draft** pull request for the current branch (Scout perf optimizations). + + 1. Ensure all changes are committed; push `git push -u origin HEAD`. + 2. Read `$ARTIFACTS_DIR/plan.md` and `$ARTIFACTS_DIR/routes-summary.md` for context. + 3. If a PR already exists: `gh pr list --head $(git branch --show-current)` — report URL and skip creation. + 4. Else use the repo PR template if present; fill every section. + 5. Create with: `gh pr create --draft --base $BASE_BRANCH` — title like `perf: Scout hot-route optimizations`. + 6. Save `gh pr view --json number,url` to `$ARTIFACTS_DIR/.pr-url` if helpful. + + Summarize PR URL and draft status. + depends_on: [validate] + context: fresh + + # ═══════════════════════════════════════════════════════════════ + # PHASE 6 — Code review (same pattern as archon-fix-github-issue) + # ═══════════════════════════════════════════════════════════════ + + - id: review-scope + command: archon-pr-review-scope + depends_on: [create-draft-pr] + context: fresh + + - id: review-classify + prompt: | + You are a PR review classifier for a Scout performance PR. + + ## PR scope + $review-scope.output + + Rules: + - **Code review**: ALWAYS run (`true`). + - **Error handling**: run if diff touches try/catch, async error paths, or new failure modes. + - **Test coverage**: run if diff touches non-test source. + - **Comment quality**: run if diff changes comments/docstrings meaningfully. + - **Docs impact**: run if public APIs, env vars, or user-facing behavior docs change. + + Provide brief reasoning. + depends_on: [review-scope] + model: haiku + allowed_tools: [] + context: fresh + output_format: + type: object + properties: + run_code_review: + type: string + enum: ["true", "false"] + run_error_handling: + type: string + enum: ["true", "false"] + run_test_coverage: + type: string + enum: ["true", "false"] + run_comment_quality: + type: string + enum: ["true", "false"] + run_docs_impact: + type: string + enum: ["true", "false"] + reasoning: + type: string + required: + - run_code_review + - run_error_handling + - run_test_coverage + - run_comment_quality + - run_docs_impact + - reasoning + + - id: code-review + command: archon-code-review-agent + depends_on: [review-classify] + context: fresh + + - id: error-handling + command: archon-error-handling-agent + depends_on: [review-classify] + when: "$review-classify.output.run_error_handling == 'true'" + context: fresh + + - id: test-coverage + command: archon-test-coverage-agent + depends_on: [review-classify] + when: "$review-classify.output.run_test_coverage == 'true'" + context: fresh + + - id: comment-quality + command: archon-comment-quality-agent + depends_on: [review-classify] + when: "$review-classify.output.run_comment_quality == 'true'" + context: fresh + + - id: docs-impact + command: archon-docs-impact-agent + depends_on: [review-classify] + when: "$review-classify.output.run_docs_impact == 'true'" + context: fresh + + - id: synthesize + command: archon-synthesize-review + depends_on: [code-review, error-handling, test-coverage, comment-quality, docs-impact] + trigger_rule: one_success + context: fresh + + - id: self-fix + command: archon-self-fix-all + depends_on: [synthesize] + context: fresh + + - id: simplify + command: archon-simplify-changes + depends_on: [self-fix] + context: fresh + + # ═══════════════════════════════════════════════════════════════ + # PHASE 7 — Mark PR ready (review concerns addressed) + # ═══════════════════════════════════════════════════════════════ + + - id: mark-pr-ready + prompt: | + Scout perf workflow — finalize the PR. + + 1. Run validation: `bun run validate` or the repo's full check from CLAUDE.md. + 2. Push any fixes: `git push origin HEAD`. + 3. **Mark the PR ready for review** (exit draft): `gh pr ready` (or `gh pr ready ` if needed). + 4. Post a short comment on the PR summarizing: Scout routes targeted, key changes, validation status. + + If `gh pr ready` is not appropriate (already ready, or permission), report current PR state from `gh pr view`. + + Output: + - PR URL + - Draft vs open (ready) state + - Next steps for the human (optional) + depends_on: [simplify] + context: fresh diff --git a/.archon/workflows/defaults/archon-slack-feature-to-review-app.yaml b/.archon/workflows/defaults/archon-slack-feature-to-review-app.yaml new file mode 100644 index 0000000000..591c00f4a6 --- /dev/null +++ b/.archon/workflows/defaults/archon-slack-feature-to-review-app.yaml @@ -0,0 +1,411 @@ +name: archon-slack-feature-to-review-app +description: | + Use when: User on Slack/chat asks @archie to build, add, or implement a + feature end-to-end and wants a working review app at the end. Matches + phrases like "build X", "add feature Y", "implement Z", "ship a feature + that...". + Input: Feature description in natural language. + Output: PR ready for review + review-app URL posted back to the thread. + NOT for: Spec/PRD only (use archon-interactive-prd), code-only changes + without a spec (use archon-idea-to-pr), or bug fixes + (use archon-fix-github-issue). + +provider: claude +interactive: true + +nodes: + # ═══════════════════════════════════════════════════════════════ + # PHASE A — SPEC CREATION (bounded 3-iteration revision loop) + # ═══════════════════════════════════════════════════════════════ + + - id: spec + model: sonnet + loop: + prompt: | + # Feature request → spec + + You are turning a Slack-submitted feature request into a focused + implementation spec, through iterative dialogue. + + **Original request**: $ARGUMENTS + **User's latest reply**: $LOOP_USER_INPUT + + --- + + ## 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. + + ## If the user has replied: + + 1. Process their answers. + 2. If you now have enough to draft a spec, write it to + `.claude/archon/specs/.spec.md` with these sections: + - Problem + - Proposed change (which files, functions, interfaces) + - Out of scope + - Acceptance criteria (specific, testable bullets) + - Testing plan + 3. Present a condensed summary of the spec in-thread (not the full + file), end with: "Reply `approved` to implement, or tell me what + to change." + 4. If the user's latest reply EXPLICITLY approves (contains + "approved", "looks good", "ship it", "go"), emit + SPEC_APPROVED and stop. Otherwise, revise the + spec file based on their feedback and re-summarize. + + **CRITICAL**: Never emit SPEC_APPROVED unless the + user's LATEST message explicitly approves. Questions, feedback, and + change requests are NOT approval. + until: SPEC_APPROVED + max_iterations: 3 + interactive: true + gate_message: | + Answer the questions above, or reply "approved" once the spec looks right. + + - id: announce-spec-approved + depends_on: [spec] + bash: 'echo "🧠 Spec approved. Creating implementation plan..."' + + # ═══════════════════════════════════════════════════════════════ + # PHASE B — PLAN + # ═══════════════════════════════════════════════════════════════ + + - id: create-plan + command: archon-create-plan + depends_on: [announce-spec-approved] + context: fresh + + - id: refine-plan + depends_on: [create-plan] + model: sonnet + loop: + prompt: | + # Feature-to-review-app — Plan review + + The implementation plan lives at `$ARTIFACTS_DIR/plan.md`. + + **User's latest reply**: $LOOP_USER_INPUT + + --- + + ## If this is the first iteration ($LOOP_USER_INPUT is empty): + + 1. Read `$ARTIFACTS_DIR/plan.md`. + 2. Post a condensed in-thread summary (NOT the full file): goal, + ordered task list (one line each), files that will change, risks + or tradeoffs worth flagging, and anything you explicitly left + out of scope. + 3. End with: "Reply `approved` to implement, or tell me what to + change (scope, ordering, tasks to add/drop)." + + **Do not** emit `PLAN_APPROVED` on the first + iteration. + + ## If the user gave feedback: + + 1. Edit `$ARTIFACTS_DIR/plan.md` directly to incorporate the + changes (add/remove/modify tasks, tighten scope, adjust + ordering, etc.). + 2. Post a short "Changes made" summary of what you edited. + 3. Ask for more feedback or approval. + + ## If the user's latest reply EXPLICITLY approves + (contains "approved", "looks good", "ship it", "go"): + + Output: "Plan approved. Setting up the implementation worktree." + Then emit: `PLAN_APPROVED` + + **CRITICAL**: Never emit `PLAN_APPROVED` unless + the user's LATEST message explicitly approves. Questions, feedback, + and change requests are NOT approval. If the user rejects outright + (e.g. "no", "cancel", "stop"), acknowledge and wait for further + instructions — do NOT emit the approval signal. + until: PLAN_APPROVED + max_iterations: 5 + interactive: true + gate_message: | + Review the plan summary. Request changes, or reply "approved" to start implementation. + + - id: plan-setup + command: archon-plan-setup + depends_on: [refine-plan] + context: fresh + + - id: announce-plan-ready + depends_on: [plan-setup] + bash: 'echo "🏗️ Plan approved. Implementing in a fresh worktree..."' + + # ═══════════════════════════════════════════════════════════════ + # PHASE C — IMPLEMENT + VALIDATE + # ═══════════════════════════════════════════════════════════════ + + - id: implement-tasks + command: archon-implement-tasks + depends_on: [announce-plan-ready] + context: fresh + model: claude-opus-4-6[1m] + + - id: validate + command: archon-validate + depends_on: [implement-tasks] + context: fresh + + - id: announce-validated + depends_on: [validate] + bash: 'echo "✅ Implementation passed local validation. Opening PR..."' + + # ═══════════════════════════════════════════════════════════════ + # PHASE D — PR + # ═══════════════════════════════════════════════════════════════ + + - id: finalize-pr + command: archon-finalize-pr + depends_on: [announce-validated] + context: fresh + + - id: announce-pr-open + depends_on: [finalize-pr] + bash: 'echo "🔍 PR opened. Running code review (round 1 of 2)..."' + + # ═══════════════════════════════════════════════════════════════ + # PHASE E — CODE REVIEW: ROUND 1 + # (five parallel agents → synthesize → conditional fix) + # ═══════════════════════════════════════════════════════════════ + + - id: review-scope-1 + command: archon-pr-review-scope + depends_on: [announce-pr-open] + context: fresh + + - id: sync-1 + command: archon-sync-pr-with-main + depends_on: [review-scope-1] + context: fresh + + - id: code-review-1 + command: archon-code-review-agent + depends_on: [sync-1] + context: fresh + + - id: error-handling-1 + command: archon-error-handling-agent + depends_on: [sync-1] + context: fresh + + - id: test-coverage-1 + command: archon-test-coverage-agent + depends_on: [sync-1] + context: fresh + + - id: comment-quality-1 + command: archon-comment-quality-agent + depends_on: [sync-1] + context: fresh + + - id: docs-impact-1 + command: archon-docs-impact-agent + depends_on: [sync-1] + context: fresh + + - id: synthesize-1 + command: archon-synthesize-review + depends_on: + - code-review-1 + - error-handling-1 + - test-coverage-1 + - comment-quality-1 + - docs-impact-1 + trigger_rule: none_failed_min_one_success + context: fresh + output_format: + type: object + properties: + blocking_findings_count: + type: number + summary: + type: string + required: [blocking_findings_count] + + - id: announce-round-1-result + depends_on: [synthesize-1] + bash: | + count="$synthesize-1.output.blocking_findings_count" + if [ "$count" = "0" ]; then + echo "✅ Review round 1 clean. Waiting on CI..." + else + echo "🔧 Review round 1 found $count blocking issue(s). Applying fixes..." + fi + + - id: implement-fixes-1 + command: archon-implement-review-fixes + depends_on: [announce-round-1-result] + context: fresh + when: '$synthesize-1.output.blocking_findings_count > 0' + + # ═══════════════════════════════════════════════════════════════ + # PHASE E — CODE REVIEW: ROUND 2 (only if round 1 had findings) + # ═══════════════════════════════════════════════════════════════ + + - id: announce-round-2-start + depends_on: [implement-fixes-1] + bash: 'echo "🔍 Re-reviewing after fixes..."' + when: '$synthesize-1.output.blocking_findings_count > 0' + + - id: review-scope-2 + command: archon-pr-review-scope + depends_on: [announce-round-2-start] + context: fresh + when: '$synthesize-1.output.blocking_findings_count > 0' + + - id: code-review-2 + command: archon-code-review-agent + depends_on: [review-scope-2] + context: fresh + + - id: error-handling-2 + command: archon-error-handling-agent + depends_on: [review-scope-2] + context: fresh + + - id: test-coverage-2 + command: archon-test-coverage-agent + depends_on: [review-scope-2] + context: fresh + + - id: comment-quality-2 + command: archon-comment-quality-agent + depends_on: [review-scope-2] + context: fresh + + - id: docs-impact-2 + command: archon-docs-impact-agent + depends_on: [review-scope-2] + context: fresh + + - id: synthesize-2 + command: archon-synthesize-review + depends_on: + - code-review-2 + - error-handling-2 + - test-coverage-2 + - comment-quality-2 + - docs-impact-2 + trigger_rule: none_failed_min_one_success + context: fresh + output_format: + type: object + properties: + blocking_findings_count: + type: number + summary: + type: string + required: [blocking_findings_count] + + - id: review-gate + depends_on: [synthesize-1, synthesize-2] + trigger_rule: none_failed_min_one_success + bash: | + r1="$synthesize-1.output.blocking_findings_count" + r2="$synthesize-2.output.blocking_findings_count" + if [ "$r1" = "0" ]; then + echo "✅ Review clean (round 1). Waiting on CI..." + exit 0 + fi + if [ -n "$r2" ] && [ "$r2" = "0" ]; then + echo "✅ Review clean (round 2). Waiting on CI..." + exit 0 + fi + echo "⛔ Code review did not converge after 2 rounds." + echo "Round 1 summary: $synthesize-1.output.summary" + echo "Round 2 summary: $synthesize-2.output.summary" + echo "PR is open; stopping before CI and review-app deploy." + exit 1 + + # ═══════════════════════════════════════════════════════════════ + # PHASE F — WAIT FOR CI + # ═══════════════════════════════════════════════════════════════ + + - id: extract-pr-number + depends_on: [review-gate] + bash: | + set -e + number=$(gh pr view --json number --jq '.number') + if [ -z "$number" ] || [ "$number" = "null" ]; then + echo "ERROR: could not resolve PR number for current branch" >&2 + exit 1 + fi + printf '%s\n' "$number" + + - id: ci-wait + depends_on: [extract-pr-number] + timeout: 3900000 + bash: | + set -e + bun .archon/scripts/ci-wait.js "$extract-pr-number.output" 3600000 + + - id: announce-ci-pass + depends_on: [ci-wait] + bash: 'echo "🚀 CI green. Deploying review app..."' + + # ═══════════════════════════════════════════════════════════════ + # PHASE G — DISPATCH REVIEW-APP DEPLOY + # ═══════════════════════════════════════════════════════════════ + + - id: deploy-review-app + depends_on: [announce-ci-pass] + bash: | + set -e + branch=$(gh pr view --json headRefName --jq '.headRefName') + bun .archon/scripts/dispatch-review-app.js deploy-to-review-app.yml "$branch" + + # ═══════════════════════════════════════════════════════════════ + # PHASE H — FETCH REVIEW-APP URL FROM PR COMMENTS + # ═══════════════════════════════════════════════════════════════ + + - id: fetch-review-url + depends_on: [deploy-review-app] + timeout: 1000000 + bash: | + set -e + bun .archon/scripts/fetch-review-app-url.js \ + "$extract-pr-number.output" \ + 'https://[^[:space:])]+\.review\.instrumentl\.com[^[:space:])]*' \ + 900000 20000 + + # ═══════════════════════════════════════════════════════════════ + # PHASE I — FINAL POST + # ═══════════════════════════════════════════════════════════════ + + - id: announce-done + depends_on: [fetch-review-url] + model: haiku + prompt: | + Output ONLY the final status message below, with placeholders filled. + No preamble, no code fences, no commentary. + + First, resolve the PR URL: + `gh pr view --json url --jq .url` + + The review-app URL is: $fetch-review-url.output + + Message format: + + 🎉 Done! + • PR: + • Review app: $fetch-review-url.output + + Open the review app to try it out; review the PR when you're ready to merge. diff --git a/.claude/skills/archon/guides/setup.md b/.claude/skills/archon/guides/setup.md index c12ba1649d..30c651d70c 100644 --- a/.claude/skills/archon/guides/setup.md +++ b/.claude/skills/archon/guides/setup.md @@ -119,8 +119,6 @@ If Bun was just installed in Prerequisites (macOS/Linux), use `~/.bun/bin/bun` i 3. Verify: `archon version` 4. Check Claude is installed: `which claude`, then `claude /login` if needed -> **Note — Claude Code binary path.** Archon does not bundle Claude Code. In compiled Archon binaries (quick install, Homebrew), the Claude Code SDK needs `CLAUDE_BIN_PATH` set to the absolute path of its `cli.js`. The `archon setup` wizard in Step 4 auto-detects this via `npm root -g` and writes it to `~/.archon/.env` — no manual action needed in the typical case. Source installs (`bun run`) don't need this; the SDK finds `cli.js` via `node_modules` automatically. - ## Step 4: Configure Credentials The CLI loads infrastructure config (database, tokens) from `~/.archon/.env` only. This prevents conflicts with project `.env` files that may contain different database URLs. @@ -160,7 +158,7 @@ Both paths are normal — the manual path is not an error. Wait for the user to confirm they've completed the setup wizard before proceeding. -### 4c: Verify Configuration +### 5c: Verify Configuration After the user confirms setup is complete: @@ -172,7 +170,7 @@ Should show: - `Database: sqlite` (default, zero setup) or `Database: postgresql` (if DATABASE_URL was configured) - No errors about missing configuration -### 4d: Run Database Migrations (PostgreSQL only) +### 5d: Run Database Migrations (PostgreSQL only) **SQLite users: skip this step.** SQLite is auto-initialized on first run with zero setup. diff --git a/.claude/skills/archon/guides/slack.md b/.claude/skills/archon/guides/slack.md index 42011ee980..14a3293434 100644 --- a/.claude/skills/archon/guides/slack.md +++ b/.claude/skills/archon/guides/slack.md @@ -10,7 +10,7 @@ Follow the step-by-step instructions in **[docs/slack-setup.md](../../../../../d 1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps) (from scratch) 2. Enable **Socket Mode** — generates an App-Level Token (`xapp-...`) for `SLACK_APP_TOKEN` -3. Add **Bot Token Scopes**: `app_mentions:read`, `chat:write`, `channels:history`, `channels:join`, `im:history`, `im:write`, `im:read` +3. Add **Bot Token Scopes**: `app_mentions:read`, `chat:write`, `reactions:write`, `channels:history`, `channels:join`, `im:history`, `im:write`, `im:read` 4. Subscribe to **Bot Events**: `app_mention`, `message.im` 5. **Install to Workspace** — generates a Bot User OAuth Token (`xoxb-...`) for `SLACK_BOT_TOKEN` 6. Invite the bot to your channel: `/invite @YourBotName` diff --git a/.cursor/hooks/state/continual-learning-index.json b/.cursor/hooks/state/continual-learning-index.json new file mode 100644 index 0000000000..9d9eb2e7f6 --- /dev/null +++ b/.cursor/hooks/state/continual-learning-index.json @@ -0,0 +1,11 @@ +{ + "280d685f-a48a-46c6-833f-323dd405d054/280d685f-a48a-46c6-833f-323dd405d054.jsonl": { + "mtime": 1776270712 + }, + "d2ae91bc-63ac-4028-88d4-97ce68f614a8/d2ae91bc-63ac-4028-88d4-97ce68f614a8.jsonl": { + "mtime": 1776268070 + }, + "caf60f72-6726-464d-a2cf-d3e482e5d1a9/caf60f72-6726-464d-a2cf-d3e482e5d1a9.jsonl": { + "mtime": 1776259093 + } +} diff --git a/.cursor/hooks/state/continual-learning.json b/.cursor/hooks/state/continual-learning.json new file mode 100644 index 0000000000..5a75f06918 --- /dev/null +++ b/.cursor/hooks/state/continual-learning.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "lastRunAtMs": 1776270702201, + "turnsSinceLastRun": 0, + "lastTranscriptMtimeMs": 1776270702140.7751, + "lastProcessedGenerationId": "10c60213-c8e3-462b-a77e-202f0bb589a2", + "trialStartedAtMs": null +} diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000000..641de8700b --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "scout-apm": { + "command": "docker", + "args": ["run", "--rm", "-i", "--env", "SCOUT_API_KEY", "scoutapp/scout-mcp-local:latest"], + "env": { + "SCOUT_API_KEY": "${env:SCOUT_API_KEY}" + }, + "envFile": "${workspaceFolder}/.env" + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..4277831adb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,6 @@ +## Learned User Preferences + +## Learned Workspace Facts + +- Run `bun install` from the repo root before `bun run dev` or per-package dev scripts; without a linked root `node_modules`, workspace packages are not resolved and common failures include missing `vite`/`astro` and failed `@archon/paths` subpath imports (for example `@archon/paths/strip-cwd-env-boot` from `@archon/server`). +- In the Web UI, Settings → Platform Connections hardcodes Slack, Telegram, Discord, and GitHub as not connected; only Web reflects live adapter state, so a working Slack bot can still show as not configured there until the UI is wired to real platform status. diff --git a/bun.lock b/bun.lock index cf5b5efd7d..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": { @@ -225,6 +226,8 @@ }, }, "overrides": { + "axios": "^1.15.0", + "flatted": "^3.4.2", "test-exclude": "^7.0.1", }, "packages": { @@ -1040,7 +1043,7 @@ "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], - "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], + "axios": ["axios@1.15.1", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], @@ -1388,7 +1391,7 @@ "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], - "flatted": ["flatted@3.4.1", "", {}, "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ=="], + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="], @@ -2028,7 +2031,7 @@ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], diff --git a/docs/plans/2026-04-17-slack-archie-feature-to-review-app-plan.md b/docs/plans/2026-04-17-slack-archie-feature-to-review-app-plan.md new file mode 100644 index 0000000000..fd89b6e4dc --- /dev/null +++ b/docs/plans/2026-04-17-slack-archie-feature-to-review-app-plan.md @@ -0,0 +1,973 @@ +# @archie Slack feature-to-review-app 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 (`- [ ]`) syntax for tracking. + +**Goal:** Ship one bundled Archon workflow (`archon-slack-feature-to-review-app`) that takes a natural-language feature request in Slack and drives it end-to-end to a PR + deployed review app, with progress posted to the thread. + +**Architecture:** One new YAML workflow composing existing commands (spec questions from `archon-interactive-prd`, plan/implement/PR/review agents from `archon-idea-to-pr`) plus three small `.archon/scripts/` helpers (dispatch a GH Actions workflow, wait for CI, poll PR comments for the review-app URL). No adapter or orchestrator changes. Registered as a bundled default so it's available in binary builds. + +**Tech Stack:** Bun + TypeScript, Archon workflow engine (DAG + loop nodes), `gh` CLI for GitHub interactions, existing Slack adapter (no changes). + +**Related design doc:** `docs/specs/2026-04-17-slack-archie-feature-to-review-app-design.md`. + +--- + +## File Structure + +New files: +- `.archon/scripts/dispatch-review-app.ts` — shell-safe wrapper around `gh workflow run`. +- `.archon/scripts/ci-wait.ts` — polls `gh pr checks --watch` with a hard timeout; exits 0 on green, non-zero on red/timeout. +- `.archon/scripts/fetch-review-app-url.ts` — polls `gh pr view --json comments` every 20s up to 15 min, regex-extracts the first URL match. +- `.archon/workflows/defaults/archon-slack-feature-to-review-app.yaml` — the workflow. + +Modified files: +- `packages/workflows/src/defaults/bundled-defaults.ts` — register the new workflow YAML. +- `packages/workflows/src/defaults/bundled-defaults.test.ts` — extend existing parse assertion to cover it (only if a count assertion exists). + +No changes to: Slack adapter, orchestrator, DB schema, Zod config schemas. + +--- + +### Task 1: Script — dispatch-review-app.ts + +**Files:** +- Create: `.archon/scripts/dispatch-review-app.ts` + +Small shell wrapper. The workflow passes two CLI args: `` (e.g. `deploy-to-review-app.yml`) and `` (the PR branch). Exits 0 on dispatch success, non-zero with a clear message otherwise. + +- [ ] **Step 1: Write the script** + +Create `.archon/scripts/dispatch-review-app.ts` with the following contents: + +```typescript +#!/usr/bin/env bun +/** + * Dispatch a GitHub Actions workflow_dispatch event on the given ref. + * + * Usage: bun .archon/scripts/dispatch-review-app.ts + * + * Exits 0 on successful dispatch. Exits non-zero with a human-readable stderr + * message on any failure (missing args, gh not installed, gh call failed). + * + * Used by the archon-slack-feature-to-review-app workflow after CI passes + * to deploy a review app for the PR branch. + */ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +async function main(): Promise { + const [workflowFile, ref] = process.argv.slice(2); + + if (!workflowFile || !ref) { + console.error('Usage: dispatch-review-app.ts '); + process.exit(2); + } + + try { + const { stdout, stderr } = await execFileAsync('gh', [ + 'workflow', + 'run', + workflowFile, + '--ref', + ref, + ]); + if (stdout.trim()) console.log(stdout.trim()); + if (stderr.trim()) console.log(stderr.trim()); + console.log( + JSON.stringify({ dispatched: true, workflow: workflowFile, ref }) + ); + } catch (err) { + const e = err as Error & { stderr?: string }; + console.error( + `Failed to dispatch ${workflowFile} on ref ${ref}: ${e.stderr ?? e.message}` + ); + process.exit(1); + } +} + +void main(); +``` + +- [ ] **Step 2: Verify it runs with a missing-arg check** + +Run: `bun .archon/scripts/dispatch-review-app.ts; echo "exit=$?"` + +Expected: usage line on stderr, line `exit=2` on stdout. + +- [ ] **Step 3: Commit** + +```bash +git add .archon/scripts/dispatch-review-app.ts +git commit -m "feat(scripts): dispatch-review-app helper for slack feature workflow + +Wraps gh workflow run for review-app deployment; exits non-zero with a +clear message on dispatch failure. Used by archon-slack-feature-to-review-app." +``` + +--- + +### Task 2: Script — ci-wait.ts + +**Files:** +- Create: `.archon/scripts/ci-wait.ts` + +Polls `gh pr checks --watch --fail-fast` with an outer wall-clock timeout. `gh pr checks --watch` already exits 0 on all-green and 1 on any failure; we add a parent process timeout so we never hang. + +- [ ] **Step 1: Write the script** + +Create `.archon/scripts/ci-wait.ts` with the following contents: + +```typescript +#!/usr/bin/env bun +/** + * Wait for GitHub CI on a PR to finish, with a hard wall-clock timeout. + * + * Usage: bun .archon/scripts/ci-wait.ts [timeout-ms] + * + * Exit codes: + * 0 — all required checks passed + * 1 — at least one required check failed + * 3 — timeout reached before CI finished + * 2 — bad args / missing gh + * + * Used by archon-slack-feature-to-review-app to gate review-app deploy. + */ +import { spawn } from 'node:child_process'; + +const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000; // 60 minutes + +function main(): void { + const [pr, timeoutArg] = process.argv.slice(2); + + if (!pr) { + console.error('Usage: ci-wait.ts [timeout-ms]'); + process.exit(2); + } + + const timeoutMs = timeoutArg ? Number(timeoutArg) : DEFAULT_TIMEOUT_MS; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + console.error(`Invalid timeout-ms: ${timeoutArg}`); + process.exit(2); + } + + console.log( + `Waiting for CI on PR ${pr} (timeout: ${Math.round(timeoutMs / 1000)}s)...` + ); + + const child = spawn( + 'gh', + ['pr', 'checks', pr, '--watch', '--fail-fast', '--interval', '30'], + { stdio: 'inherit' } + ); + + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + console.error(`\nCI wait timed out after ${Math.round(timeoutMs / 1000)}s`); + child.kill('SIGTERM'); + setTimeout(() => process.exit(3), 2000).unref(); + }, timeoutMs); + timer.unref(); + + child.on('exit', (code, _signal) => { + clearTimeout(timer); + if (timedOut) return; + if (code === 0) { + console.log('CI passed.'); + process.exit(0); + } + console.error(`CI failed (gh exit code ${code ?? 'null'})`); + process.exit(1); + }); + + child.on('error', err => { + clearTimeout(timer); + console.error(`Failed to spawn gh: ${err.message}`); + process.exit(2); + }); +} + +main(); +``` + +- [ ] **Step 2: Verify arg validation** + +Run: `bun .archon/scripts/ci-wait.ts; echo "exit=$?"` + +Expected: usage line on stderr, `exit=2`. + +Run: `bun .archon/scripts/ci-wait.ts 99999 abc; echo "exit=$?"` + +Expected: `Invalid timeout-ms: abc` on stderr, `exit=2`. + +- [ ] **Step 3: Commit** + +```bash +git add .archon/scripts/ci-wait.ts +git commit -m "feat(scripts): ci-wait helper with hard timeout + +Wraps gh pr checks --watch --fail-fast with a wall-clock timeout so the +workflow can't hang indefinitely. Exit codes distinguish pass/fail/timeout." +``` + +--- + +### Task 3: Script — fetch-review-app-url.ts + +**Files:** +- Create: `.archon/scripts/fetch-review-app-url.ts` + +Polls the PR's comments via `gh pr view --json comments` every 20 seconds for up to 15 minutes, looking for a URL matching a caller-supplied regex. Prints the URL on stdout and exits 0 on match; non-zero on timeout. Log lines go to stderr so `$nodeId.output` captures only the URL. + +- [ ] **Step 1: Write the script** + +Create `.archon/scripts/fetch-review-app-url.ts` with the following contents: + +```typescript +#!/usr/bin/env bun +/** + * Poll a GitHub PR's comments for a review-app URL matching a regex. + * + * Usage: + * bun .archon/scripts/fetch-review-app-url.ts [timeout-ms] [interval-ms] + * + * Exit codes: + * 0 — URL found; printed to stdout as the only stdout line + * 3 — timeout reached without a match + * 2 — bad args / gh failure / invalid regex / bad comments JSON + * + * The workflow consumes the trimmed stdout via $.output. + * All log lines go to stderr so the URL is the only stdout content. + */ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; +const DEFAULT_INTERVAL_MS = 20 * 1000; + +interface CommentShape { + body?: string; +} + +async function pollOnce( + pr: string, + regex: RegExp +): Promise { + const { stdout } = await execFileAsync('gh', [ + 'pr', + 'view', + pr, + '--json', + 'comments', + ]); + let parsed: { comments?: CommentShape[] }; + try { + parsed = JSON.parse(stdout); + } catch { + throw new Error(`gh returned non-JSON stdout: ${stdout.slice(0, 200)}`); + } + const comments = parsed.comments ?? []; + for (const c of comments) { + const match = typeof c.body === 'string' ? c.body.match(regex) : null; + if (match) return match[0]; + } + return null; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function main(): Promise { + const [pr, regexStr, timeoutArg, intervalArg] = process.argv.slice(2); + + if (!pr || !regexStr) { + console.error( + 'Usage: fetch-review-app-url.ts [timeout-ms] [interval-ms]' + ); + process.exit(2); + } + + let regex: RegExp; + try { + regex = new RegExp(regexStr); + } catch (err) { + console.error( + `Invalid regex ${JSON.stringify(regexStr)}: ${(err as Error).message}` + ); + process.exit(2); + } + + const timeoutMs = timeoutArg ? Number(timeoutArg) : DEFAULT_TIMEOUT_MS; + const intervalMs = intervalArg ? Number(intervalArg) : DEFAULT_INTERVAL_MS; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + console.error(`Invalid timeout-ms: ${timeoutArg}`); + process.exit(2); + } + if (!Number.isFinite(intervalMs) || intervalMs <= 0) { + console.error(`Invalid interval-ms: ${intervalArg}`); + process.exit(2); + } + + const deadline = Date.now() + timeoutMs; + console.error( + `Polling PR ${pr} for pattern ${regex} every ${Math.round(intervalMs / 1000)}s, up to ${Math.round(timeoutMs / 1000)}s total...` + ); + + while (Date.now() < deadline) { + try { + const match = await pollOnce(pr, regex); + if (match) { + console.log(match); + return; + } + } catch (err) { + console.error(`Poll error (will retry): ${(err as Error).message}`); + } + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + await sleep(Math.min(intervalMs, remaining)); + } + + console.error( + `No matching comment found on PR ${pr} within ${Math.round(timeoutMs / 1000)}s.` + ); + process.exit(3); +} + +void main(); +``` + +- [ ] **Step 2: Verify arg validation** + +Run: `bun .archon/scripts/fetch-review-app-url.ts; echo "exit=$?"` + +Expected: usage line on stderr, `exit=2`. + +Run: `bun .archon/scripts/fetch-review-app-url.ts 1 '[' 5000; echo "exit=$?"` + +Expected: `Invalid regex "["...` on stderr, `exit=2`. + +- [ ] **Step 3: Commit** + +```bash +git add .archon/scripts/fetch-review-app-url.ts +git commit -m "feat(scripts): fetch-review-app-url helper + +Polls gh pr view --json comments for a URL matching a caller-supplied +regex; prints the URL on stdout, errors on stderr so the workflow engine +captures only the URL via \$nodeId.output." +``` + +--- + +### Task 4: Workflow YAML — archon-slack-feature-to-review-app + +**Files:** +- Create: `.archon/workflows/defaults/archon-slack-feature-to-review-app.yaml` + +The main artifact. Implementation decisions: +- Spec revision is a `loop:` node with `interactive: true` matching `archon-piv-loop`'s `refine-plan` pattern. Signal: `SPEC_APPROVED`, `max_iterations: 3`. +- Code review "2 rounds max" is **explicitly unrolled** (not a `loop:` node) because loops are single-prompt-bodied and our review needs a 5-parallel-agents sub-graph. Round 2 uses `when:` to skip itself when round 1 was clean. +- Scripts are invoked via `bash:` wrappers (not `script:` nodes) because `script:` nodes do not accept CLI args. +- Instrumentl-specific review-app parameters (`deploy-to-review-app.yml`, `*.review.instrumentl.com` regex) are hardcoded as literal strings. Per-project overrides are future work. + +- [ ] **Step 1: Write the workflow YAML** + +Create `.archon/workflows/defaults/archon-slack-feature-to-review-app.yaml` with: + +```yaml +name: archon-slack-feature-to-review-app +description: | + Use when: User on Slack/chat asks @archie to build, add, or implement a + feature end-to-end and wants a working review app at the end. Matches + phrases like "build X", "add feature Y", "implement Z", "ship a feature + that...". + Input: Feature description in natural language. + Output: PR ready for review + review-app URL posted back to the thread. + NOT for: Spec/PRD only (use archon-interactive-prd), code-only changes + without a spec (use archon-idea-to-pr), or bug fixes + (use archon-fix-github-issue). + +provider: claude +interactive: true + +nodes: + # ═══════════════════════════════════════════════════════════════ + # PHASE A — SPEC CREATION (bounded 3-iteration revision loop) + # ═══════════════════════════════════════════════════════════════ + + - id: spec + model: sonnet + loop: + prompt: | + # Feature request → spec + + You are turning a Slack-submitted feature request into a focused + implementation spec, through iterative dialogue. + + **Original request**: $ARGUMENTS + **User's latest reply**: $LOOP_USER_INPUT + + --- + + ## 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 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." + 5. Do NOT emit the approval signal yet. + + ## If the user has replied: + + 1. Process their answers. + 2. If you now have enough to draft a spec, write it to + `.claude/archon/specs/.spec.md` with these sections: + - Problem + - Proposed change (which files, functions, interfaces) + - Out of scope + - Acceptance criteria (specific, testable bullets) + - Testing plan + 3. Present a condensed summary of the spec in-thread (not the full + file), end with: "Reply `approved` to implement, or tell me what + to change." + 4. If the user's latest reply EXPLICITLY approves (contains + "approved", "looks good", "ship it", "go"), emit + SPEC_APPROVED and stop. Otherwise, revise the + spec file based on their feedback and re-summarize. + + **CRITICAL**: Never emit SPEC_APPROVED unless the + user's LATEST message explicitly approves. Questions, feedback, and + change requests are NOT approval. + until: SPEC_APPROVED + max_iterations: 3 + interactive: true + gate_message: | + Answer the questions above, or reply "approved" once the spec looks right. + + - id: announce-spec-approved + depends_on: [spec] + bash: 'echo "🧠 Spec approved. Creating implementation plan..."' + + # ═══════════════════════════════════════════════════════════════ + # PHASE B — PLAN + # ═══════════════════════════════════════════════════════════════ + + - id: create-plan + command: archon-create-plan + depends_on: [announce-spec-approved] + context: fresh + + - id: plan-setup + command: archon-plan-setup + depends_on: [create-plan] + context: fresh + + - id: announce-plan-ready + depends_on: [plan-setup] + bash: 'echo "🏗️ Plan ready. Implementing in a fresh worktree..."' + + # ═══════════════════════════════════════════════════════════════ + # PHASE C — IMPLEMENT + VALIDATE + # ═══════════════════════════════════════════════════════════════ + + - id: implement-tasks + command: archon-implement-tasks + depends_on: [announce-plan-ready] + context: fresh + model: claude-opus-4-6[1m] + + - id: validate + command: archon-validate + depends_on: [implement-tasks] + context: fresh + + - id: announce-validated + depends_on: [validate] + bash: 'echo "✅ Implementation passed local validation. Opening PR..."' + + # ═══════════════════════════════════════════════════════════════ + # PHASE D — PR + # ═══════════════════════════════════════════════════════════════ + + - id: finalize-pr + command: archon-finalize-pr + depends_on: [announce-validated] + context: fresh + + - id: announce-pr-open + depends_on: [finalize-pr] + bash: 'echo "🔍 PR opened. Running code review (round 1 of 2)..."' + + # ═══════════════════════════════════════════════════════════════ + # PHASE E — CODE REVIEW: ROUND 1 + # (five parallel agents → synthesize → conditional fix) + # ═══════════════════════════════════════════════════════════════ + + - id: review-scope-1 + command: archon-pr-review-scope + depends_on: [announce-pr-open] + context: fresh + + - id: sync-1 + command: archon-sync-pr-with-main + depends_on: [review-scope-1] + context: fresh + + - id: code-review-1 + command: archon-code-review-agent + depends_on: [sync-1] + context: fresh + + - id: error-handling-1 + command: archon-error-handling-agent + depends_on: [sync-1] + context: fresh + + - id: test-coverage-1 + command: archon-test-coverage-agent + depends_on: [sync-1] + context: fresh + + - id: comment-quality-1 + command: archon-comment-quality-agent + depends_on: [sync-1] + context: fresh + + - id: docs-impact-1 + command: archon-docs-impact-agent + depends_on: [sync-1] + context: fresh + + - id: synthesize-1 + command: archon-synthesize-review + depends_on: + - code-review-1 + - error-handling-1 + - test-coverage-1 + - comment-quality-1 + - docs-impact-1 + trigger_rule: none_failed_min_one_success + context: fresh + output_format: + type: object + properties: + blocking_findings_count: + type: number + summary: + type: string + required: [blocking_findings_count] + + - id: announce-round-1-result + depends_on: [synthesize-1] + bash: | + count="$synthesize-1.output.blocking_findings_count" + if [ "$count" = "0" ]; then + echo "✅ Review round 1 clean. Waiting on CI..." + else + echo "🔧 Review round 1 found $count blocking issue(s). Applying fixes..." + fi + + - id: implement-fixes-1 + command: archon-implement-review-fixes + depends_on: [announce-round-1-result] + context: fresh + when: '$synthesize-1.output.blocking_findings_count > 0' + + # ═══════════════════════════════════════════════════════════════ + # PHASE E — CODE REVIEW: ROUND 2 (only if round 1 had findings) + # ═══════════════════════════════════════════════════════════════ + + - id: announce-round-2-start + depends_on: [implement-fixes-1] + bash: 'echo "🔍 Re-reviewing after fixes..."' + when: '$synthesize-1.output.blocking_findings_count > 0' + + - id: review-scope-2 + command: archon-pr-review-scope + depends_on: [announce-round-2-start] + context: fresh + when: '$synthesize-1.output.blocking_findings_count > 0' + + - id: code-review-2 + command: archon-code-review-agent + depends_on: [review-scope-2] + context: fresh + + - id: error-handling-2 + command: archon-error-handling-agent + depends_on: [review-scope-2] + context: fresh + + - id: test-coverage-2 + command: archon-test-coverage-agent + depends_on: [review-scope-2] + context: fresh + + - id: comment-quality-2 + command: archon-comment-quality-agent + depends_on: [review-scope-2] + context: fresh + + - id: docs-impact-2 + command: archon-docs-impact-agent + depends_on: [review-scope-2] + context: fresh + + - id: synthesize-2 + command: archon-synthesize-review + depends_on: + - code-review-2 + - error-handling-2 + - test-coverage-2 + - comment-quality-2 + - docs-impact-2 + trigger_rule: none_failed_min_one_success + context: fresh + output_format: + type: object + properties: + blocking_findings_count: + type: number + summary: + type: string + required: [blocking_findings_count] + + - id: review-gate + depends_on: [synthesize-1, synthesize-2] + trigger_rule: none_failed_min_one_success + bash: | + r1="$synthesize-1.output.blocking_findings_count" + r2="$synthesize-2.output.blocking_findings_count" + if [ "$r1" = "0" ]; then + echo "✅ Review clean (round 1). Waiting on CI..." + exit 0 + fi + if [ -n "$r2" ] && [ "$r2" = "0" ]; then + echo "✅ Review clean (round 2). Waiting on CI..." + exit 0 + fi + echo "⛔ Code review did not converge after 2 rounds." + echo "Round 1 summary: $synthesize-1.output.summary" + echo "Round 2 summary: $synthesize-2.output.summary" + echo "PR is open; stopping before CI and review-app deploy." + exit 1 + + # ═══════════════════════════════════════════════════════════════ + # PHASE F — WAIT FOR CI + # ═══════════════════════════════════════════════════════════════ + + - id: extract-pr-number + depends_on: [review-gate] + bash: | + set -e + number=$(gh pr view --json number --jq '.number') + if [ -z "$number" ] || [ "$number" = "null" ]; then + echo "ERROR: could not resolve PR number for current branch" >&2 + exit 1 + fi + printf '%s\n' "$number" + + - id: ci-wait + depends_on: [extract-pr-number] + timeout: 3900000 + bash: | + set -e + bun .archon/scripts/ci-wait.ts "$extract-pr-number.output" 3600000 + + - id: announce-ci-pass + depends_on: [ci-wait] + bash: 'echo "🚀 CI green. Deploying review app..."' + + # ═══════════════════════════════════════════════════════════════ + # PHASE G — DISPATCH REVIEW-APP DEPLOY + # ═══════════════════════════════════════════════════════════════ + + - id: deploy-review-app + depends_on: [announce-ci-pass] + bash: | + set -e + branch=$(gh pr view --json headRefName --jq '.headRefName') + bun .archon/scripts/dispatch-review-app.ts deploy-to-review-app.yml "$branch" + + # ═══════════════════════════════════════════════════════════════ + # PHASE H — FETCH REVIEW-APP URL FROM PR COMMENTS + # ═══════════════════════════════════════════════════════════════ + + - id: fetch-review-url + depends_on: [deploy-review-app] + timeout: 1000000 + bash: | + set -e + bun .archon/scripts/fetch-review-app-url.ts \ + "$extract-pr-number.output" \ + 'https://[^[:space:])]+\.review\.instrumentl\.com[^[:space:])]*' \ + 900000 20000 + + # ═══════════════════════════════════════════════════════════════ + # PHASE I — FINAL POST + # ═══════════════════════════════════════════════════════════════ + + - id: announce-done + depends_on: [fetch-review-url] + model: haiku + prompt: | + Output ONLY the final status message below, with placeholders filled. + No preamble, no code fences, no commentary. + + First, resolve the PR URL: + `gh pr view --json url --jq .url` + + The review-app URL is: $fetch-review-url.output + + Message format: + + 🎉 Done! + • PR: + • Review app: $fetch-review-url.output + + Open the review app to try it out; review the PR when you're ready to merge. +``` + +- [ ] **Step 2: Validate the workflow parses** + +Run: + +```bash +bun run cli validate workflows archon-slack-feature-to-review-app +``` + +Expected: no errors. All referenced commands (`archon-create-plan`, `archon-plan-setup`, `archon-implement-tasks`, `archon-validate`, `archon-finalize-pr`, `archon-pr-review-scope`, `archon-sync-pr-with-main`, `archon-code-review-agent`, `archon-error-handling-agent`, `archon-test-coverage-agent`, `archon-comment-quality-agent`, `archon-docs-impact-agent`, `archon-synthesize-review`, `archon-implement-review-fixes`) must exist. All referenced scripts (`ci-wait`, `dispatch-review-app`, `fetch-review-app-url`) must exist in `.archon/scripts/`. + +If the validator complains about a missing command, verify it exists under `.archon/commands/defaults/` or in the validator's discovery path. If missing, the design assumed it existed — reopen the design doc and adjust. + +If the validator complains about `when:` expression syntax or `output_format` keys, fix according to the error message. + +- [ ] **Step 3: Commit** + +```bash +git add .archon/workflows/defaults/archon-slack-feature-to-review-app.yaml +git commit -m "feat(workflows): archon-slack-feature-to-review-app + +End-to-end workflow for Slack @archie feature requests: interactive spec +creation (bounded 3-iteration revision loop), plan + implement + PR using +existing commands, two-round code review with conditional second pass, CI +wait, review-app dispatch, URL fetch from PR comments, and final post back +to the Slack thread. Composes existing commands; adds no new adapter or +orchestrator code." +``` + +--- + +### Task 5: Register the workflow in bundled defaults + +**Files:** +- Modify: `packages/workflows/src/defaults/bundled-defaults.ts` +- Modify: `packages/workflows/src/defaults/bundled-defaults.test.ts` (only if a count assertion exists) + +Binary builds read bundled workflows from a compile-time text import map. Add the new workflow to both the import list and the exported map. + +- [ ] **Step 1: Add the import** + +Open `packages/workflows/src/defaults/bundled-defaults.ts`. In the workflow imports section, after the line importing `archonWorkflowBuilderWf`, add: + +```typescript +import archonSlackFeatureToReviewAppWf from '../../../../.archon/workflows/defaults/archon-slack-feature-to-review-app.yaml' with { type: 'text' }; +``` + +- [ ] **Step 2: Add the map entry** + +In the same file, in the `BUNDLED_WORKFLOWS` map, after the line `'archon-workflow-builder': archonWorkflowBuilderWf,` add: + +```typescript + 'archon-slack-feature-to-review-app': archonSlackFeatureToReviewAppWf, +``` + +- [ ] **Step 3: Inspect the existing test for a count assertion** + +Run: + +```bash +grep -n "13\|BUNDLED_WORKFLOWS\|toHaveLength\|Object.keys" packages/workflows/src/defaults/bundled-defaults.test.ts +``` + +If you see an assertion like `expect(Object.keys(BUNDLED_WORKFLOWS)).toHaveLength(13)`, update the `13` to `14`. + +If there is no count assertion (the test just iterates and parses), no test change is needed — the new entry is covered automatically. + +- [ ] **Step 4: Run the bundled-defaults test** + +Run: + +```bash +cd packages/workflows && bun test src/defaults/bundled-defaults.test.ts +``` + +Expected: all tests pass, including the parse check on the new workflow. + +If parsing fails because the workflow references a command not present in `BUNDLED_COMMANDS`, that means binary builds need the command too. Add the missing command's import and map entry to `BUNDLED_COMMANDS` following the same pattern as the other 21 commands in that file. + +- [ ] **Step 5: Type-check** + +From repo root: + +```bash +bun run type-check +``` + +Expected: no new type errors. The `with { type: 'text' }` import syntax is already used 34 times in this file. + +- [ ] **Step 6: Commit** + +```bash +git add packages/workflows/src/defaults/bundled-defaults.ts \ + packages/workflows/src/defaults/bundled-defaults.test.ts +git commit -m "feat(workflows): register archon-slack-feature-to-review-app in bundled defaults + +Make the new end-to-end Slack workflow available in binary builds alongside +the existing bundled workflows." +``` + +--- + +### Task 6: Pre-PR validation + +No new code; run the project's standard validation gate. + +- [ ] **Step 1: Run full validation** + +From repo root: + +```bash +bun run validate +``` + +Expected: type-check, lint, format, and tests all pass. + +- [ ] **Step 2: Fix any flagged issues inline** + +If lint flags anything in the new script files, fix inline. Do not silence warnings with `eslint-disable` — the repo enforces zero warnings per `CLAUDE.md`. + +- [ ] **Step 3: Commit fixups (only if needed)** + +```bash +git status --short +``` + +If anything changed in step 2: + +```bash +git add -A +git commit -m "chore: fix lint/format for new slack feature workflow" +``` + +Otherwise skip. + +--- + +### Task 7: Manual smoke test (one-time, after merge) + +Verification checklist to run ONCE against a real Slack workspace after merge. Not part of CI. Document outcomes in the PR description. + +- [ ] **Step 1: Confirm environment** + +Check `.env` contains: +- `SLACK_BOT_TOKEN` (xoxb-*) +- `SLACK_APP_TOKEN` (xapp-*) +- `SLACK_ALLOWED_USER_IDS` including your Slack user ID +- `ANTHROPIC_API_KEY` +- `GITHUB_TOKEN` with workflow dispatch permissions on the target repo + +Confirm a codebase pointing at the target repo is registered in Archon. + +Start the server: + +```bash +bun run dev +``` + +- [ ] **Step 2: Trigger with a trivial request** + +In the connected Slack channel, post: + +``` +@archie add a README badge linking to the docs site +``` + +- [ ] **Step 3: Verify spec phase** + +Expected in-thread: +1. Bot restates the request and asks 3-5 targeted questions. +2. Reply with answers. +3. Bot drafts a spec summary and asks for approval. +4. Reply `approved`. +5. Announce: `🧠 Spec approved. Creating implementation plan...` appears. + +- [ ] **Step 4: Verify implement + PR** + +Expected announces, in order: +1. `🏗️ Plan ready. Implementing in a fresh worktree...` +2. `✅ Implementation passed local validation. Opening PR...` +3. `🔍 PR opened. Running code review (round 1 of 2)...` + +Confirm a real PR exists in the target repo with the generated branch. + +- [ ] **Step 5: Verify review loop** + +Expected either: +- `✅ Review round 1 clean. Waiting on CI...` (clean path) + +or: +- `🔧 Review round 1 found N blocking issue(s). Applying fixes...` +- `🔍 Re-reviewing after fixes...` +- Then one of: + - `✅ Review clean (round 2). Waiting on CI...` + - `⛔ Code review did not converge after 2 rounds.` (terminal) + +- [ ] **Step 6: Verify CI + deploy** + +Expected: +- `🚀 CI green. Deploying review app...` +- `deploy-to-review-app.yml` workflow run appears in GitHub Actions for the PR branch. + +- [ ] **Step 7: Verify URL fetch + final post** + +Expected within 15 minutes: +- Final message: `🎉 Done! • PR: • Review app: ` +- Clicking the review-app URL loads the deployed app. + +- [ ] **Step 8: Record results in the PR description** + +Add a `## Smoke test` section with pass/fail per step, links to the Slack thread, and any follow-ups discovered. + +--- + +## Self-Review Notes + +**Spec coverage check** — each design doc section maps to a task: +- Trigger + routing: no work needed (existing Slack adapter + router). +- Configuration: deferred; values hardcoded in YAML for v1 (documented divergence below). +- Workflow node graph phases A–I: Task 4. +- Progress announcements: inline in Task 4 (bash echo nodes). +- Authorization: no work needed (existing `SLACK_ALLOWED_USER_IDS`). +- Failure modes: script exit codes in Tasks 1–3; `review-gate` bash node in Task 4 handles the 2-round cap. +- Testing: Task 5 (bundled-defaults parse test) + Task 7 (manual smoke). Unit tests for scripts dropped; justified below. +- Implementation artifacts: Tasks 1–5. + +**Placeholder scan:** No `TBD`, `TODO`, or "implement later" markers. Exact commands and complete code in every code step. + +**Type consistency check:** Script CLI signatures (`process.argv` contracts) match the `bash:` wrapper invocations in Task 4. Script file names (`dispatch-review-app.ts`, `ci-wait.ts`, `fetch-review-app-url.ts`) match across Tasks 1–3 and the workflow invocations in Task 4. + +**Divergence from design doc (noted for reviewers):** + +1. **Code-review "2 rounds" unrolled** into explicit nodes rather than a `loop:` node, because loop bodies are single-prompt and cannot wrap the 5-parallel-agents sub-graph. Same net behavior, more verbose YAML. +2. **`reviewApp` config schema dropped for v1.** Values hardcoded in the YAML (`deploy-to-review-app.yml`, `*.review.instrumentl.com` regex). Per-project overrides become work when the second project opts in. +3. **Unit tests for helper scripts dropped.** No existing test pattern for `.archon/scripts/` (the existing `echo-*.js` files have none), and writing one would require new scaffolding. Workflow-level parse test + manual smoke test + defensive script arg validation provide pragmatic coverage. 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/docs/specs/2026-04-17-slack-archie-feature-to-review-app-design.md b/docs/specs/2026-04-17-slack-archie-feature-to-review-app-design.md new file mode 100644 index 0000000000..9bd6da4f15 --- /dev/null +++ b/docs/specs/2026-04-17-slack-archie-feature-to-review-app-design.md @@ -0,0 +1,320 @@ +# Slack @archie: feature request → review app + +**Status:** Design approved, ready for implementation plan +**Date:** 2026-04-17 +**Workflow name:** `archon-slack-feature-to-review-app` + +## Problem + +When a teammate has a feature idea, the path from "I wish we had X" to "there's a +working review app I can try" takes days and crosses many tools (spec doc, Jira +ticket, branch, PR, review, CI, deploy). Most of those steps are mechanical. + +We want a single Slack interaction — `@archie, build a feature to do X` — to drive +the entire loop: clarify the idea, write a spec, get approval, implement in an +isolated worktree, open a PR, run code review until clean, wait for CI, deploy a +review app, and post the review-app URL back to the thread. + +Primary target repo: **instrumentl/instrumentl**. Designed so a second project can +opt in later by registering its codebase with Archon and setting two config +values; no code changes required per new project. + +## Non-Goals + +- Replacing structured product discovery for large initiatives. This is for + features small enough that a PRD-style spec is overkill — one Slack ask, one PR. +- Bug fixes (use `archon-fix-github-issue`). +- Spec-only / PRD-only workflows (use `archon-interactive-prd`). +- Merging the PR. The final artifact is a review app + a PR ready for human + review and merge. + +## Success Criteria + +- A user in Slack tags `@archie` with a feature request and, without leaving the + thread, is asked 3 clarifying question rounds, receives a spec to approve, + then receives ongoing progress updates and a final review-app URL. +- Works end-to-end against `instrumentl/instrumentl` with no custom code beyond + the workflow YAML and 3 helper scripts. +- Re-targeting at a second project requires only: registering the codebase in + Archon, setting `reviewApp.workflowFile` and `reviewApp.urlCommentPattern` in + that repo's `.archon/config.yaml`. + +## Approach Summary + +One new bundled workflow in `packages/workflows/src/defaults/workflows/` that +composes existing commands (spec questions from `archon-interactive-prd`, plan + +implement + PR + review agents from `archon-idea-to-pr`) and adds three small +new pieces: + +1. A bounded 3-iteration spec revision loop. +2. A bounded 2-round code-review loop with an exit condition on "no blocking + findings". +3. Three new bash/script helpers: wait for CI, dispatch the review-app GitHub + Actions workflow, poll PR comments for the review-app URL. + +Plus ~7 lightweight `prompt:` announce nodes at phase boundaries that stream +status lines to the Slack thread. + +## Trigger + Routing + +No custom Slack adapter work. The flow uses existing infrastructure: + +- `SlackAdapter.start()` fires on `app_mention` — strips the mention, passes + text to the orchestrator. +- The orchestrator's router (`archon-assist`) matches workflow `description` + fields. This workflow's description matches phrases like `build X`, `add + feature Y`, `implement Z`, `ship a feature that...`. +- Conversation ID = `channel:thread_ts` — every message and gate response stays + in the same thread. +- The worktree branch is auto-generated from the feature slug, e.g., + `archie/csv-grant-export-2026-04-17`. + +## Configuration + +New optional `reviewApp` section in `.archon/config.yaml` (per-project): + +```yaml +reviewApp: + workflowFile: deploy-to-review-app.yml + urlCommentPattern: 'https://[^\s)]+\.review\.instrumentl\.com[^\s)]*' +``` + +Defaults target Instrumentl. Missing values fall back to sensible defaults; +`urlCommentPattern` not matching any comment after the polling window fails +loudly with a clear error rather than silently succeeding. + +## Workflow Node Graph + +File: `packages/workflows/src/defaults/workflows/archon-slack-feature-to-review-app.yaml`. + +Header: + +```yaml +name: archon-slack-feature-to-review-app +description: | + Use when: A user on Slack/chat asks @archie to build, add, or implement a + feature end-to-end and wants a working review app at the end. Matches phrases + like "build X", "add feature Y", "implement Z", "ship a feature that...". + Input: Feature description in natural language. + Output: Merged-ready PR + review-app URL posted back to the requesting thread. + NOT for: Spec/PRD only (use archon-interactive-prd), code-only changes without + a spec (use archon-idea-to-pr), or bug fixes (use archon-fix-github-issue). +interactive: true +provider: claude +``` + +### Phases + +**A. Spec creation (interactive, bounded 3-iteration revision loop)** +- Reuse foundation / deep-dive / scope question nodes from + `archon-interactive-prd`, each gated by `approval: capture_response: true`. +- `spec-generate` writes to `$ARTIFACTS_DIR/specs/.spec.md`. +- `spec-approval-loop`: `loop:` node wrapping a revise prompt + an approval + gate. Exit on approve. `$REJECTION_REASON` feeds revision. Max 3 iterations. + On cap-hit: post "Spec revision limit reached..." and fail gracefully. + +**B. Plan** +- `archon-create-plan` with the spec path. +- `archon-plan-setup` creates the worktree + branch. + +**C. Implement + validate** +- `archon-implement-tasks` on `claude-opus-4-6[1m]`. +- `archon-validate` runs `bun run validate`. + +**D. PR creation** +- `archon-finalize-pr` opens the PR and marks it ready. PR URL/number flows + forward via `$finalize-pr.output`. + +**E. Code review loop (bounded 2 rounds)** +Single `loop:` node, exit condition "no blocking findings" from +`archon-synthesize-review`, max 2 iterations. Body: +- `review-scope`, `sync`. +- Five parallel review agents: `archon-code-review-agent`, + `archon-error-handling-agent`, `archon-test-coverage-agent`, + `archon-comment-quality-agent`, `archon-docs-impact-agent`. +- `archon-synthesize-review`. +- `archon-implement-review-fixes`. +- On cap-hit with unresolved blocking findings: post findings to Slack and + stop before Phase F. Don't deploy broken code. + +**F. Wait for CI** +- `ci-wait`: `bash:` / `script:` node wrapping + `gh pr checks --watch --fail-fast --interval 30`. 60-minute timeout + (configurable). +- On red: one additional call to `archon-implement-review-fixes` with CI logs + attached as context (separate node from Phase E; does NOT re-enter the review + loop — this is a CI-failure-specific fix pass, not a code-review pass), + followed by one retry of `ci-wait`. Still red → stop with logs posted to + Slack. + +**G. Trigger review app** +- `deploy-review-app`: `bash:` node — `gh workflow run ${reviewApp.workflowFile} + --ref `. + +**H. Fetch review-app URL** +- `fetch-review-url`: `bash:` / `script:` node polling + `gh pr view --json comments` every 20s for up to 15 min, grepping for + `reviewApp.urlCommentPattern`. Extracts the first match. Fails loudly with a + clear message if not found in window. + +**I. Final post to Slack** +- `announce-done`: `prompt:` node emits the final message with PR URL, + review-app URL, review-loop iterations used, total time. Because the + workflow is `interactive: true`, output streams to the Slack thread. + +### Progress Announcements (Option A — inline) + +Short `prompt:` announce nodes at phase boundaries, each directed to print +exactly one status line. Uses `haiku` / cheapest model available. Streams to +Slack via the `interactive: true` mechanism. + +Fixed announces (always fire on happy path): 6 — after spec approval, after +plan, after implementation+validation, after PR creation, after review passes, +after CI passes. The final "done" message is `announce-done` in Phase I. + +Additional announces inside loops (fire variably): review-round-start, +fixes-applied, re-reviewing, CI-failed-retrying. Expect 6–10 total on the +happy path depending on how many review/CI loop iterations run. + +Example sequence: + +``` +🧠 Spec approved. Creating implementation plan... +🏗️ Plan ready. Spinning up worktree and implementing... +✅ Implementation passed local validation. Opening PR... +🔍 PR # opened. Running code review (round 1 of 2)... +🔧 Review found blocking issues. Applying fixes... +🔍 Re-reviewing... +✅ Review clean. Waiting on CI... +🚀 CI green. Deploying review app... +🎉 Done. PR: Review app: +``` + +Intermediate announces between review rounds are emitted inside the loop body; +the sequence shown is the happy-path flow. + +### Dependency Graph + +Strictly linear A → B → C → D → E → F → G → H → I. Parallelism lives inside +phase E's review-loop body only. + +## Authorization + +- `SLACK_ALLOWED_USER_IDS` (existing) gates who can talk to the bot at all. +- Any authorized user in the thread can approve/reject/provide feedback at + any gate. Matches team norms; no second-layer approver list. + +## Failure Modes + +Each case posts a single explanatory message to the thread. + +**Spec phase** +- User abandons mid-questionnaire → 24h approval-gate timeout → "No response in + 24h — cancelling. Tag @archie again when ready." +- Spec revision cap hit → "Spec revision limit reached. Your last feedback: + . Please re-tag @archie with a tighter description." + +**Plan / implement / validate** +- Plan step errors → existing error propagation posts to thread. +- Validation still red after internal retries → "Implementation didn't pass + local validation. Last error: . PR not created." +- Worktree issues → existing `classifyIsolationError` mapping. + +**PR / review** +- `gh pr create` errors → raw `gh` error posted. +- Review loop cap hit with unresolved blockers → "Code review didn't converge + after 2 rounds. PR open at . Remaining blocking findings: ..." + +**CI** +- CI goes red → one fix retry, then "CI still failing after 1 fix attempt. + PR: . Latest CI logs: ..." +- CI timeout (60 min) → "CI hasn't completed in 60 min. PR: ." + +**Review app** +- `gh workflow run` dispatch fails → "Couldn't trigger . PR is ready at ; deploy manually." +- URL not found in 15-min window → "Review app dispatched but no matching URL + appeared in PR comments. Pattern: . PR: ." + +**Cross-cutting** +- `/workflow abandon` → standard engine behavior. +- Archon server restart → `/workflow resume ` works; interactive workflow + resumes from last completed node. +- Slack thread archived mid-run → platform `sendMessage` errors logged, run + completes in DB; user sees results in Archon web UI. + +## Testing + +**Static validation** +- `bun run cli validate workflows archon-slack-feature-to-review-app` — YAML + schema, command refs, `depends_on` edges, `$nodeId.output` refs. +- Added to `bundled-defaults.test.ts`'s "all bundled workflows parse" assertion. + +**Unit tests (new scripts in `.archon/scripts/`)** +- `ci-wait.ts` — mocked `execFileAsync` for green / red / timeout cases. +- `fetch-review-app-url.ts` — first-poll match, eventual-poll match, no-match + timeout, invalid JSON. +- `dispatch-review-app.ts` — invoked with expected args. + +**Integration test (one)** +Run the workflow through the executor with: +- `MockAgentProvider` returning canned AI responses. +- `execFileAsync` mocked for all `gh` calls. +- In-memory platform adapter capturing `sendMessage`. + +Assert: the expected happy-path announce messages land in order on the +captured platform, workflow reaches `done`, final message contains both PR URL +and review-app URL. Exact count asserted is the fixed happy-path set (6 + +final = 7); variable review/CI announces are not count-asserted to keep the +test non-brittle. + +**Manual validation checklist (first real run)** +1. Tag `@archie` with a trivial feature request. +2. Verify 3 question gates ask, answers feed spec. +3. Verify approve path; trigger reject-with-feedback once to confirm revision + loop. +4. Verify PR created with correct branch name. +5. Verify review loop runs; synthesize output sane. +6. Verify `gh workflow run deploy-to-review-app.yml` fires after CI. +7. Verify review-app URL parsed from PR comment and posted to thread. + +**Explicitly NOT testing:** Slack adapter (already covered), the 5 review +agents (already covered), `gh` CLI behavior. + +## Implementation Artifacts + +New files: +- `packages/workflows/src/defaults/workflows/archon-slack-feature-to-review-app.yaml` +- `.archon/scripts/ci-wait.ts` +- `.archon/scripts/fetch-review-app-url.ts` +- `.archon/scripts/dispatch-review-app.ts` +- Corresponding `*.test.ts` next to each script. + +Modified files: +- `packages/workflows/src/defaults/bundled-defaults.ts` — register the new + workflow YAML import. +- `packages/workflows/src/defaults/bundled-defaults.test.ts` — ensure the new + workflow parses. + +No changes to: +- `packages/adapters/src/chat/slack/` — Slack adapter as-is. +- `packages/core/src/orchestrator/` — routing as-is. +- Database schema. + +## Open Decisions for the Implementation Plan + +- Exact `.archon/config.yaml` schema location for `reviewApp` (top-level key + vs. nested under `codebase`). Leaning top-level `reviewApp:`. +- Whether announce nodes should use `sonnet` or `haiku` — leaning cheapest + option that reliably emits exact text, TBD during implementation. +- Naming: `announce-*` node prefix vs. emoji-first in-node IDs. Cosmetic; pick + during implementation. + +## Follow-Up Work (Not in This Design) + +- Option B for progress (workflow-event-driven Slack updates) — revisit if more + workflows need identical announce patterns. +- Auto-merge of the PR once review-app is validated by a human "ship it" reply + — separate workflow. +- Support for non-GitHub review-app deploy mechanisms (e.g., direct HTTP + webhook) — only if a second project needs it. diff --git a/package.json b/package.json index b296d638ca..2994b42916 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,9 @@ "bun": "^1.3.0" }, "overrides": { - "test-exclude": "^7.0.1" + "test-exclude": "^7.0.1", + "flatted": "^3.4.2", + "axios": "^1.15.0" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.74" 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 283353dee1..b46ae8c963 100644 --- a/packages/adapters/src/chat/slack/adapter.test.ts +++ b/packages/adapters/src/chat/slack/adapter.test.ts @@ -26,10 +26,14 @@ mock.module('@archon/paths', () => ({ // Create mock functions const mockPostMessage = mock(() => Promise.resolve(undefined)); const mockReplies = mock(() => Promise.resolve({ messages: [] })); +const mockReactionsAdd = mock(() => Promise.resolve({ ok: true })); 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: { @@ -38,8 +42,13 @@ const mockApp = { conversations: { replies: mockReplies, }, + reactions: { + add: mockReactionsAdd, + }, }, event: mockEvent, + action: mockAction, + view: mockView, start: mockStart, stop: mockStop, }; @@ -366,4 +375,392 @@ describe('SlackAdapter', () => { ); }); }); + + describe('interactive-loop gate rendering', () => { + let adapter: SlackAdapter; + + beforeEach(() => { + adapter = new SlackAdapter('xoxb-fake', 'xapp-fake'); + mockPostMessage.mockClear(); + }); + + test('renders Approve + Request changes buttons when interactiveGate is set', async () => { + await adapter.sendMessage('C123:1234.5678', 'Review the plan summary.', { + interactiveGate: { runId: 'run-abc', nodeId: 'refine-plan' }, + }); + + expect(mockPostMessage).toHaveBeenCalledTimes(1); + const call = (mockPostMessage as Mock).mock.calls[0][0] as { + blocks: unknown[]; + }; + expect(call.blocks).toHaveLength(2); + const actionsBlock = call.blocks[1] as { + type: string; + block_id: string; + elements: Array<{ action_id: string; style?: string; text: { text: string } }>; + }; + expect(actionsBlock.type).toBe('actions'); + expect(actionsBlock.block_id).toBe('gate_block|run-abc|refine-plan'); + expect(actionsBlock.elements).toHaveLength(2); + expect(actionsBlock.elements[0].action_id).toBe('gate_approve|run-abc|refine-plan'); + expect(actionsBlock.elements[0].style).toBe('primary'); + expect(actionsBlock.elements[0].text.text).toBe('Approve'); + expect(actionsBlock.elements[1].action_id).toBe('gate_request_changes|run-abc|refine-plan'); + expect(actionsBlock.elements[1].text.text).toBe('Request changes'); + }); + + test('omits gate buttons when no interactiveGate metadata is present', async () => { + await adapter.sendMessage('C123:1234.5678', 'plain message'); + + const call = (mockPostMessage as Mock).mock.calls[0][0] as { + blocks: unknown[]; + }; + expect(call.blocks).toHaveLength(1); + expect((call.blocks[0] as { type: string }).type).toBe('markdown'); + }); + + test('attaches buttons only to the final chunk of a long message', async () => { + const paragraph1 = 'a'.repeat(10000); + const paragraph2 = 'b'.repeat(10000); + const message = `${paragraph1}\n\n${paragraph2}`; + + await adapter.sendMessage('C123', message, { + interactiveGate: { runId: 'run-1', nodeId: 'gate-n' }, + }); + + expect(mockPostMessage).toHaveBeenCalledTimes(2); + const first = (mockPostMessage as Mock).mock.calls[0][0] as { + blocks: unknown[]; + }; + const second = (mockPostMessage as Mock).mock.calls[1][0] as { + blocks: unknown[]; + }; + // First chunk has only markdown; second chunk has markdown + actions. + expect(first.blocks).toHaveLength(1); + expect(second.blocks).toHaveLength(2); + expect((second.blocks[1] as { type: string }).type).toBe('actions'); + }); + }); + + describe('acknowledgeReceipt', () => { + const event: SlackMessageEvent = { + text: 'hello', + user: 'U123', + channel: 'C456', + ts: '1234567890.000001', + }; + + beforeEach(() => { + mockReactionsAdd.mockClear(); + }); + + test('posts :eyes: reaction on the incoming message', async () => { + const adapter = new SlackAdapter('xoxb-fake', 'xapp-fake'); + await adapter.acknowledgeReceipt(event); + + expect(mockReactionsAdd).toHaveBeenCalledTimes(1); + const args = (mockReactionsAdd as Mock).mock.calls[0][0] as { + channel: string; + timestamp: string; + name: string; + }; + expect(args.channel).toBe('C456'); + expect(args.timestamp).toBe('1234567890.000001'); + expect(args.name).toBe('eyes'); + }); + + test('does not throw when reactions:write scope is missing', async () => { + // Simulate Slack's `missing_scope` error shape. + const scopeError = Object.assign(new Error('missing_scope'), { + data: { error: 'missing_scope', needed: 'reactions:write' }, + }); + mockReactionsAdd.mockImplementationOnce(() => Promise.reject(scopeError)); + + const adapter = new SlackAdapter('xoxb-fake', 'xapp-fake'); + // If this rejected, the test runner would surface it — proving graceful handling. + await adapter.acknowledgeReceipt(event); + expect(mockReactionsAdd).toHaveBeenCalledTimes(1); + }); + + test('silently skips when message already has the reaction', async () => { + const alreadyReacted = Object.assign(new Error('already_reacted'), { + data: { error: 'already_reacted' }, + }); + mockReactionsAdd.mockImplementationOnce(() => Promise.reject(alreadyReacted)); + + const adapter = new SlackAdapter('xoxb-fake', 'xapp-fake'); + await adapter.acknowledgeReceipt(event); + 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 e74a069356..cef456f2b3 100644 --- a/packages/adapters/src/chat/slack/adapter.ts +++ b/packages/adapters/src/chat/slack/adapter.ts @@ -9,6 +9,58 @@ 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 + * with a non-colliding separator so the Slack callback can recover them + * without depending on out-of-band state. Separator `|` is safe: workflow + * runIds are UUIDs and node ids are YAML keys (no pipes). + */ +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}`; +} + +function decodeGateActionId( + actionId: string | undefined +): { runId: string; nodeId: string } | null { + if (!actionId) return null; + const parts = actionId.split(GATE_SEP); + if (parts.length !== 3) return null; + const [, runId, nodeId] = parts; + if (!runId || !nodeId) return null; + return { runId, nodeId }; +} + +/** + * Block type used for Slack message blocks. We don't import @slack/types + * directly — the adapter package only declares @slack/bolt, and the exact + * block shape is validated at runtime by Slack's API. An opaque record keeps + * the compile boundary narrow while still allowing typed construction. + */ +type SlackBlock = Record; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ let cachedLog: ReturnType | undefined; @@ -49,12 +101,13 @@ export class SlackAdapter implements IPlatformAdapter { * Send a message to a Slack channel/thread * Uses markdown block for proper formatting of AI responses * Automatically splits messages longer than 12000 characters + * + * When `metadata.interactiveGate` is set, the final chunk is followed by + * an Approve / Request changes action block. All other adapters ignore the + * field, so the text body already includes the `/workflow approve` fallback + * and remains complete on every platform. */ - async sendMessage( - channelId: string, - message: string, - _metadata?: MessageMetadata - ): Promise { + async sendMessage(channelId: string, message: string, metadata?: MessageMetadata): Promise { getLog().debug({ channelId, messageLength: message.length }, 'slack.send_message'); // Parse channelId - may include thread_ts as "channel:thread_ts" @@ -62,55 +115,347 @@ export class SlackAdapter implements IPlatformAdapter { ? channelId.split(':') : [channelId, undefined]; + const gate = metadata?.interactiveGate; + if (message.length <= MAX_MARKDOWN_BLOCK_LENGTH) { - // Use markdown block for proper formatting - await this.sendWithMarkdownBlock(channel, message, threadTs); + await this.sendWithMarkdownBlock(channel, message, threadTs, gate); } else { - // Long message: split by paragraphs getLog().debug({ messageLength: message.length }, 'slack.message_splitting'); const chunks = splitIntoParagraphChunks(message, MAX_MARKDOWN_BLOCK_LENGTH - 500); - for (const chunk of chunks) { - await this.sendWithMarkdownBlock(channel, chunk, threadTs); + // Attach gate buttons only to the LAST chunk so a long gate prompt still + // ends with a single actionable row. + for (let i = 0; i < chunks.length; i++) { + const isLast = i === chunks.length - 1; + await this.sendWithMarkdownBlock(channel, chunks[i], threadTs, isLast ? gate : undefined); } } } /** - * Send a message using Slack's markdown block for proper formatting - * Falls back to plain text if block fails + * Send a message using Slack's markdown block for proper formatting. + * Falls back to plain text if block fails. + * + * When `gate` is provided, append an Actions block with Approve / Request + * changes buttons whose action_ids carry the runId + nodeId. This lets the + * user resolve interactive-loop gates with one click instead of typing + * `/workflow approve `. */ private async sendWithMarkdownBlock( channel: string, message: string, - threadTs?: string + threadTs?: string, + gate?: { runId: string; nodeId: string } ): Promise { + 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 { + // Cast through `unknown`: SlackBlock is an opaque record by design + // (see type definition). Slack's runtime validates the exact shape, and + // the pre-refactor markdown block was already being cast implicitly. await this.app.client.chat.postMessage({ channel, thread_ts: threadTs, - blocks: [ - { - type: 'markdown', - text: message, - }, - ], + blocks, // Fallback text for notifications/accessibility - text: message.substring(0, 150) + (message.length > 150 ? '...' : ''), - }); - getLog().debug({ messageLength: message.length }, 'slack.markdown_block_sent'); + text: cleanedMessage.substring(0, 150) + (cleanedMessage.length > 150 ? '...' : ''), + } as unknown as Parameters[0]); + getLog().debug( + { + messageLength: cleanedMessage.length, + gate: Boolean(gate), + hasQuestions: Boolean(questions), + }, + 'slack.markdown_block_sent' + ); } catch (error) { - // Fallback to plain text + // Fallback to plain text. Gate buttons are sacrificed in this fallback + // path; the message body still contains the `/workflow approve ...` + // instructions so the user retains a way to resolve the gate. const err = error as Error; getLog().warn({ err, channel, threadTs }, 'slack.markdown_block_failed'); await this.app.client.chat.postMessage({ channel, thread_ts: threadTs, - text: message, + text: cleanedMessage, }); } } + /** + * Build the Actions block with Approve (primary) and Request changes + * (neutral) buttons. Action ids pack the workflow run + node so the click + * callback can resolve the target without consulting DB state. + */ + private buildGateActionsBlock(gate: { runId: string; nodeId: string }): SlackBlock { + return { + type: 'actions', + block_id: encodeGateActionId('gate_block', gate.runId, gate.nodeId), + elements: [ + { + type: 'button', + action_id: encodeGateActionId(GATE_ACTION_APPROVE, gate.runId, gate.nodeId), + style: 'primary', + text: { type: 'plain_text', text: 'Approve', emoji: true }, + value: 'approve', + }, + { + type: 'button', + action_id: encodeGateActionId(GATE_ACTION_REQUEST_CHANGES, gate.runId, gate.nodeId), + text: { type: 'plain_text', text: 'Request changes', emoji: true }, + value: 'request_changes', + }, + ], + }; + } + + /** + * 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 */ @@ -238,10 +583,44 @@ export class SlackAdapter implements IPlatformAdapter { this.messageHandler = handler; } + /** + * Post an :eyes: reaction to the incoming message so the user knows the + * bot received the request immediately — before thread-history fetch, + * orchestration, lock acquisition, or first LLM token. + * + * Intentionally silent on failure: + * - `reactions:write` scope is optional; missing-scope workspaces still + * get a working bot, just without the visual receipt. + * - We never want a reaction error to block message processing. + */ + async acknowledgeReceipt(event: SlackMessageEvent): Promise { + try { + await this.app.client.reactions.add({ + channel: event.channel, + timestamp: event.ts, + name: 'eyes', + }); + getLog().debug({ channel: event.channel, ts: event.ts }, 'slack.receipt_ack_sent'); + } catch (error) { + const err = error as Error & { data?: { error?: string } }; + // `already_reacted` just means we're re-processing; not worth a warn. + if (err.data?.error === 'already_reacted') { + getLog().debug({ channel: event.channel }, 'slack.receipt_ack_already_reacted'); + return; + } + getLog().warn( + { err, slackError: err.data?.error, channel: event.channel }, + 'slack.receipt_ack_failed' + ); + } + } + /** * Start the bot (connects via Socket Mode) */ async start(): Promise { + this.registerGateHandlers(); + // Register app_mention event handler (when bot is @mentioned) this.app.event('app_mention', async ({ event }) => { // Authorization check @@ -311,4 +690,400 @@ export class SlackAdapter implements IPlatformAdapter { void this.app.stop(); getLog().info('slack.bot_stopped'); } + + /** + * Register Bolt handlers for gate buttons + the "Request changes" modal. + * + * Behavior: + * - Approve click → synthesize a message event with text "approved" in the + * gate's thread; the natural-language approval path in handleMessage + * resumes the paused workflow run. + * - Request changes click → open a modal; text entered on submit is + * synthesized as a thread message (treated as feedback by the loop). + * + * Action IDs are matched with the `gate_approve`/`gate_request_changes` + * prefixes (exact action_ids are per-run and per-node). We register pattern + * matchers so every live gate uses the same handler without per-run + * subscriptions. + */ + private registerGateHandlers(): void { + // Approve button. + this.app.action( + { type: 'block_actions', action_id: new RegExp(`^${GATE_ACTION_APPROVE}\\|`) }, + async ({ ack, body, action, client }) => { + await ack(); + await this.handleGateClick({ + body, + action, + client, + verb: 'approve', + }); + } + ); + + // Request changes button — opens a modal to collect feedback text. + this.app.action( + { type: 'block_actions', action_id: new RegExp(`^${GATE_ACTION_REQUEST_CHANGES}\\|`) }, + async ({ ack, body, action, client }) => { + await ack(); + await this.handleRequestChangesClick({ body, action, client }); + } + ); + + // 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 }); + }); + } + + /** + * Handle Approve click: synthesize an "approved" message in the original + * thread so the natural-language resume path fires. + */ + private async handleGateClick(params: { + body: unknown; + action: unknown; + // Typed as `unknown` because Bolt's client union is verbose; we only + // call two well-known methods on it. Actual runtime value is a WebClient. + client: unknown; + verb: 'approve'; + }): Promise { + const { body, action, client } = params; + const ctx = this.extractClickContext(body, action); + const ids = decodeGateActionId((action as { action_id?: string }).action_id); + if (!ctx) { + getLog().warn({ ids }, 'slack.gate_click_missing_context'); + return; + } + getLog().info( + { runId: ids?.runId, nodeId: ids?.nodeId, userId: ctx.userId }, + 'slack.gate_approve_clicked' + ); + + // Best-effort: replace the actions row with a status context so the + // buttons can't be clicked twice. Failure here is non-fatal. + const webClient = client as { + chat: { update: (args: Record) => Promise }; + }; + try { + await webClient.chat.update({ + channel: ctx.channel, + ts: ctx.messageTs, + text: `Approved by <@${ctx.userId}>`, + blocks: [ + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `:white_check_mark: Approved by <@${ctx.userId}>`, + }, + ], + }, + ], + }); + } catch (error) { + getLog().warn({ err: error }, 'slack.gate_update_message_failed'); + } + + await this.dispatchSyntheticMessage({ + channel: ctx.channel, + threadTs: ctx.threadTs, + userId: ctx.userId, + text: 'approved', + }); + } + + /** + * Handle Request changes click: open a modal with a multiline textarea. + * channel + thread + user are packed into `private_metadata` so the modal + * submission handler can post a synthetic reply in the correct thread. + */ + private async handleRequestChangesClick(params: { + body: unknown; + action: unknown; + // See handleGateClick for rationale. + 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.gate_changes_click_missing_context'); + return; + } + getLog().info( + { runId: ids?.runId, nodeId: ids?.nodeId, userId: ctx.userId }, + 'slack.gate_request_changes_clicked' + ); + + const privateMetadata = JSON.stringify({ + channel: ctx.channel, + threadTs: ctx.threadTs, + userId: ctx.userId, + }); + + const webClient = client as { + views: { open: (args: Record) => Promise }; + }; + try { + await webClient.views.open({ + trigger_id: triggerId, + view: { + type: 'modal', + callback_id: GATE_MODAL_CALLBACK, + private_metadata: privateMetadata, + title: { type: 'plain_text', text: 'Request changes' }, + submit: { type: 'plain_text', text: 'Send' }, + close: { type: 'plain_text', text: 'Cancel' }, + blocks: [ + { + type: 'input', + block_id: 'feedback_block', + label: { + type: 'plain_text', + text: 'What should change?', + }, + element: { + type: 'plain_text_input', + action_id: 'feedback_input', + multiline: true, + placeholder: { + type: 'plain_text', + text: 'e.g., drop the telemetry task, reorder DB migration first, tighten scope to auth only', + }, + }, + }, + ], + }, + }); + } catch (error) { + getLog().error({ err: error }, 'slack.gate_modal_open_failed'); + } + } + + /** + * Handle modal submission: post the user's feedback text as a synthetic + * thread message so the workflow's interactive loop receives it. + */ + private async handleGateModalSubmit(params: { view: unknown; body: unknown }): Promise { + const { view, body } = params; + const v = view as { + private_metadata?: string; + state?: { values?: Record> }; + }; + + let meta: { channel?: string; threadTs?: string; userId?: string } = {}; + try { + meta = v.private_metadata ? (JSON.parse(v.private_metadata) as typeof meta) : {}; + } catch { + getLog().warn('slack.gate_modal_bad_private_metadata'); + return; + } + const feedback = v.state?.values?.feedback_block?.feedback_input?.value?.trim(); + if (!meta.channel || !meta.threadTs || !feedback) { + getLog().warn('slack.gate_modal_missing_fields'); + return; + } + + // Prefer user id from body (authoritative) over private_metadata. + const userId = (body as { user?: { id?: string } }).user?.id ?? meta.userId ?? 'unknown'; + + await this.dispatchSyntheticMessage({ + channel: meta.channel, + threadTs: meta.threadTs, + userId, + text: feedback, + }); + } + + /** + * 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 + * practice; a defensive nullcheck keeps the handler resilient to future + * Bolt payload changes). + */ + private extractClickContext( + body: unknown, + _action: unknown + ): { channel: string; messageTs: string; threadTs: string; userId: string } | null { + const b = body as { + channel?: { id?: string }; + message?: { ts?: string; thread_ts?: string }; + container?: { thread_ts?: string }; + user?: { id?: string }; + }; + const channel = b.channel?.id; + const messageTs = b.message?.ts; + const threadTs = b.message?.thread_ts ?? b.container?.thread_ts ?? messageTs; + const userId = b.user?.id; + if (!channel || !messageTs || !threadTs || !userId) return null; + return { channel, messageTs, threadTs, userId }; + } + + private extractTriggerId(body: unknown): string | undefined { + return (body as { trigger_id?: string }).trigger_id; + } + + /** + * Invoke the registered message handler with a synthetic event so button + * clicks and modal submissions reuse the normal handleMessage pipeline + * (including conversation-lock serialization and isolation context). + */ + private async dispatchSyntheticMessage(params: { + channel: string; + threadTs: string; + userId: string; + text: string; + }): Promise { + if (!this.messageHandler) { + getLog().warn('slack.gate_synthetic_no_handler'); + return; + } + const event: SlackMessageEvent = { + text: params.text, + user: params.userId, + channel: params.channel, + // `ts` of a synthetic reply: reuse thread_ts; the orchestrator does not + // persist this field, and no Slack API call depends on it. + ts: params.threadTs, + thread_ts: params.threadTs, + }; + try { + await this.messageHandler(event); + } catch (error) { + getLog().error({ err: error }, 'slack.gate_synthetic_dispatch_failed'); + } + } } diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 0235ceec3e..6bb7d74797 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -1030,7 +1030,7 @@ async function collectSlackConfig(): Promise { ' - Settings -> Socket Mode -> Enable\n' + ' - Generate an App-Level Token (xapp-...)\n' + '3. Add Bot Token Scopes (OAuth & Permissions):\n' + - ' - app_mentions:read, chat:write, channels:history\n' + + ' - app_mentions:read, chat:write, reactions:write, channels:history\n' + ' - channels:join, im:history, im:write, im:read\n' + '4. Subscribe to Bot Events (Event Subscriptions):\n' + ' - app_mention, message.im\n' + diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 74966e3b2c..36120ee3a7 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -113,6 +113,14 @@ export interface MessageMetadata { segment?: 'new' | 'auto'; workflowDispatch?: { workerConversationId: string; workflowName: string }; workflowResult?: { workflowName: string; runId: string }; + /** + * When set, the message is an interactive-loop gate prompt. Adapters that + * support rich input (e.g. Slack Block Kit) may render approve / request- + * changes controls bound to this run + node. Adapters without rich input + * MUST send the plain text body unchanged; the underlying text already + * includes the /workflow approve fallback instructions. + */ + interactiveGate?: { runId: string; nodeId: string }; } export interface IPlatformAdapter { diff --git a/packages/docs-web/src/content/docs/adapters/slack.md b/packages/docs-web/src/content/docs/adapters/slack.md index ce53956793..e414e9aec5 100644 --- a/packages/docs-web/src/content/docs/adapters/slack.md +++ b/packages/docs-web/src/content/docs/adapters/slack.md @@ -55,6 +55,7 @@ Archon uses **Socket Mode** for Slack integration, which means: 3. Add these scopes to bot token scopes: - `app_mentions:read` -- Receive @mention events - `chat:write` -- Send messages + - `reactions:write` -- Post :eyes: receipt reaction on incoming messages (optional; bot works without it) - `channels:history` -- Read messages in public channels (for thread context) - `channels:join` -- Allow bot to join public channels - `groups:history` -- Read messages in private channels (optional) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3d0d1bdcf5..82ed2ed3bd 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -404,6 +404,10 @@ export async function startServer(opts: ServerOptions = {}): Promise { const content = slackAdapter.stripBotMention(event.text); if (!content) return; // Message was only a mention with no content + // Immediate receipt ack (:eyes:). Fire-and-forget — we don't want to + // delay thread-history fetch or orchestration on a reaction round-trip. + void slackAdapter.acknowledgeReceipt(event); + // Check for thread context let threadContext: string | undefined; let parentConversationId: string | undefined; diff --git a/packages/workflows/src/dag-executor.test.ts b/packages/workflows/src/dag-executor.test.ts index c5822197e5..6345c9e0ec 100644 --- a/packages/workflows/src/dag-executor.test.ts +++ b/packages/workflows/src/dag-executor.test.ts @@ -3404,6 +3404,21 @@ describe('executeDagWorkflow -- resume with priorCompletedNodes', () => { iteration: 1, message: 'Review the plan and provide feedback.', }); + + // Gate-send should carry interactiveGate metadata so adapters like Slack + // can render approve/request-changes UI instead of asking users to type + // `/workflow approve `. + const sendMessageMock = platform.sendMessage as Mock< + (id: string, msg: string, meta?: Record) => Promise + >; + const gateSendCall = sendMessageMock.mock.calls.find(call => { + const body = call[1]; + return typeof body === 'string' && body.includes('Input required'); + }); + expect(gateSendCall).toBeDefined(); + expect(gateSendCall?.[2]).toMatchObject({ + interactiveGate: { runId: 'dag-test-run-id', nodeId: 'refine' }, + }); }); it('interactive loop first iteration always gates even if AI emits signal', async () => { diff --git a/packages/workflows/src/dag-executor.ts b/packages/workflows/src/dag-executor.ts index 3680af28b5..a3a5b7e1b8 100644 --- a/packages/workflows/src/dag-executor.ts +++ b/packages/workflows/src/dag-executor.ts @@ -1869,10 +1869,13 @@ async function executeLoopNode( `\u23f8 **Input required** (loop \`${node.id}\`, iteration ${String(i)}): ${loop.gate_message}\n\n` + `Run ID: \`${workflowRun.id}\`\n` + `Respond: \`/workflow approve ${workflowRun.id} \` | Cancel: \`/workflow reject ${workflowRun.id}\``; - const gateSent = await safeSendMessage(platform, conversationId, gateMsg, { - workflowId: workflowRun.id, - nodeName: node.id, - }); + const gateSent = await safeSendMessage( + platform, + conversationId, + gateMsg, + { workflowId: workflowRun.id, nodeName: node.id }, + { interactiveGate: { runId: workflowRun.id, nodeId: node.id } } + ); if (!gateSent) { // Gate message failed to deliver — do not pause; fail the node so the user // sees a clear error rather than a silently orphaned paused run. diff --git a/packages/workflows/src/defaults/bundled-defaults.test.ts b/packages/workflows/src/defaults/bundled-defaults.test.ts index e1e1cb5a30..5f46f6eff2 100644 --- a/packages/workflows/src/defaults/bundled-defaults.test.ts +++ b/packages/workflows/src/defaults/bundled-defaults.test.ts @@ -91,13 +91,14 @@ describe('bundled-defaults', () => { 'archon-piv-loop', 'archon-adversarial-dev', 'archon-workflow-builder', + 'archon-slack-feature-to-review-app', ]; for (const wf of expectedWorkflows) { expect(BUNDLED_WORKFLOWS).toHaveProperty(wf); } - expect(Object.keys(BUNDLED_WORKFLOWS)).toHaveLength(13); + expect(Object.keys(BUNDLED_WORKFLOWS)).toHaveLength(14); }); it('should have non-empty content for all workflows', () => { diff --git a/packages/workflows/src/defaults/bundled-defaults.ts b/packages/workflows/src/defaults/bundled-defaults.ts index a921171b9e..e6b335d652 100644 --- a/packages/workflows/src/defaults/bundled-defaults.ts +++ b/packages/workflows/src/defaults/bundled-defaults.ts @@ -53,6 +53,7 @@ import archonInteractivePrdWf from '../../../../.archon/workflows/defaults/archo import archonPivLoopWf from '../../../../.archon/workflows/defaults/archon-piv-loop.yaml' with { type: 'text' }; import archonAdversarialDevWf from '../../../../.archon/workflows/defaults/archon-adversarial-dev.yaml' with { type: 'text' }; import archonWorkflowBuilderWf from '../../../../.archon/workflows/defaults/archon-workflow-builder.yaml' with { type: 'text' }; +import archonSlackFeatureToReviewAppWf from '../../../../.archon/workflows/defaults/archon-slack-feature-to-review-app.yaml' with { type: 'text' }; // ============================================================================= // Exports @@ -102,6 +103,7 @@ export const BUNDLED_WORKFLOWS: Record = { 'archon-piv-loop': archonPivLoopWf, 'archon-adversarial-dev': archonAdversarialDevWf, 'archon-workflow-builder': archonWorkflowBuilderWf, + 'archon-slack-feature-to-review-app': archonSlackFeatureToReviewAppWf, }; /** diff --git a/packages/workflows/src/deps.ts b/packages/workflows/src/deps.ts index e8fccfca41..e11988ff00 100644 --- a/packages/workflows/src/deps.ts +++ b/packages/workflows/src/deps.ts @@ -47,6 +47,8 @@ export interface WorkflowMessageMetadata { segment?: 'new' | 'auto'; workflowDispatch?: { workerConversationId: string; workflowName: string }; workflowResult?: { workflowName: string; runId: string }; + /** Mirror of MessageMetadata.interactiveGate — see packages/core/src/types/index.ts. */ + interactiveGate?: { runId: string; nodeId: string }; } // ---------------------------------------------------------------------------