From 793fbe7e748564fb8fa9eb13e0430b6550ca521d Mon Sep 17 00:00:00 2001 From: Igor Jericevich Date: Sun, 19 Jul 2026 21:51:23 +1000 Subject: [PATCH 1/4] feat(plugin): add synapse-coder-reporter learning-loop plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects LSP diagnostics after edit/write/apply_patch, holds the failing edit as pending, and reports an original->corrected pair to the Synapse Coder staging MCP (coder_report_correction) only when a follow-up same-file edit lands clean — the live schema requires non-empty original/corrected, so one-sided detections are dropped after 30 min. - Env-var opt-in gate (SYNAPSE_CODER_REPORTER_ENABLED, default off) with first-use TUI toast (once-per-project marker) - Offline queue (.opencode/synapse-coder-queue.json) with retry on load + 5-min timer; fire-and-forget hooks (no await on report path) - MCP wiring for synapse-coder + Alterspective servers in .opencode/opencode.jsonc (local entries fixed to schema shape) - 28 tests covering detection, pairing rules, opt-in, queue, model tracking, fire-and-forget, first-use toast - Plan/evidence docs under docs/implementation/current/SYN-001-* --- .opencode/.gitignore | 2 + .opencode/opencode.jsonc | 45 +- .opencode/plugin/synapse-coder-reporter.ts | 322 ++++++++++++ .../SYN-001-synapse-coder-reporter/INDEX.md | 122 +++++ .../SYN-001-synapse-coder-reporter/README.md | 75 +++ .../acceptance-criteria.md | 37 ++ .../ai-handover.md | 50 ++ .../ai-memory.md | 47 ++ .../checklist.md | 45 ++ .../evidence/investigation-findings.md | 169 ++++++ .../evidence/phase1-mcp-verification.md | 69 +++ .../evidence/phase4-e2e-verification.md | 96 ++++ .../impact-analysis.md | 57 ++ .../SYN-001-synapse-coder-reporter/issues.md | 9 + .../module-register.md | 41 ++ .../modules/synapse-coder-reporter/INDEX.md | 12 + .../modules/synapse-coder-reporter/README.md | 9 + .../synapse-coder-reporter/ai-handover.md | 10 + .../synapse-coder-reporter/ai-memory.md | 10 + .../synapse-coder-reporter/checklist.md | 16 + .../synapse-coder-reporter/requirements.md | 15 + .../modules/synapse-coder-reporter/status.md | 6 + .../technical-design.md | 76 +++ .../requirements.md | 52 ++ .../SYN-001-synapse-coder-reporter/status.md | 52 ++ .../technical-design.md | 240 +++++++++ .../synapse-coder-reporter.test.ts | 487 ++++++++++++++++++ 27 files changed, 2170 insertions(+), 1 deletion(-) create mode 100644 .opencode/plugin/synapse-coder-reporter.ts create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/INDEX.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/README.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/acceptance-criteria.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/ai-handover.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/ai-memory.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/checklist.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/evidence/investigation-findings.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/evidence/phase1-mcp-verification.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/evidence/phase4-e2e-verification.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/impact-analysis.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/issues.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/module-register.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/INDEX.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/README.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/ai-handover.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/ai-memory.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/checklist.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/requirements.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/status.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/technical-design.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/requirements.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/status.md create mode 100644 docs/implementation/current/SYN-001-synapse-coder-reporter/technical-design.md create mode 100644 packages/opencode/test/plugin/synapse-coder-reporter/synapse-coder-reporter.test.ts diff --git a/.opencode/.gitignore b/.opencode/.gitignore index c072cfe07099..eeea7eead80c 100644 --- a/.opencode/.gitignore +++ b/.opencode/.gitignore @@ -5,3 +5,5 @@ bun.lock .gitignore package-lock.json references/ +synapse-coder-queue.json +synapse-coder-prompted diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index b0f7d59447db..0d8d5366e727 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -12,7 +12,50 @@ "description": "Contains opencode logs and data", }, }, - "mcp": {}, + "mcp": { + "synapse-coder": { + "type": "remote", + "url": "https://synapse-coder-mcp-staging.greenbay-703e5a45.australiaeast.azurecontainerapps.io/mcp", + "headers": { + "Authorization": "Bearer {env:SYNAPSE_CODER_STAGING_BEARER_TOKEN}", + }, + }, + "alterspective-rag": { + "type": "remote", + "url": "https://rag.alterspective.com.au/mcp", + "headers": { + "Authorization": "Bearer {env:ALTERSPECTIVE_RAG_MCP_API_KEY}", + }, + }, + "keystone": { + "type": "remote", + "url": "https://identity.alterspective.com.au/api/mcp", + "headers": { + "Authorization": "Bearer {env:KEYSTONE_AI_ADMIN_API_KEY}", + }, + }, + "timely": { + "type": "remote", + "url": "https://timely-mcp.alterspective.com.au/mcp", + }, + "sharedo": { + "type": "remote", + "url": "https://sharedo-api.alterspective.com.au/mcp", + }, + "vault-local": { + "type": "local", + "command": ["node", "C:/GitHub/vault-mcp/dist/index.js"], + "enabled": true, + }, + "azure-devops": { + "type": "local", + "command": ["npx", "-y", "azure-devops-mcp"], + "enabled": true, + "environment": { + "AZURE_DEVOPS_PAT": "{env:AZURE_DEVOPS_PAT}", + }, + }, + }, "tools": { "github-triage": false, "github-pr-search": false, diff --git a/.opencode/plugin/synapse-coder-reporter.ts b/.opencode/plugin/synapse-coder-reporter.ts new file mode 100644 index 000000000000..6ae42d6c2ab9 --- /dev/null +++ b/.opencode/plugin/synapse-coder-reporter.ts @@ -0,0 +1,322 @@ +import type { Plugin } from "@opencode-ai/plugin" +import path from "node:path" + +const LANGUAGE_MAP: Record = { + ".ts": "typescript", ".tsx": "typescript", + ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript", + ".py": "python", + ".go": "go", + ".rs": "rust", + ".java": "java", + ".cs": "csharp", + ".rb": "ruby", + ".php": "php", + ".swift": "swift", + ".kt": "kotlin", ".kts": "kotlin", + ".dart": "dart", + ".css": "css", + ".html": "html", + ".json": "json", + ".yaml": "yaml", ".yml": "yaml", + ".md": "markdown", + ".sql": "sql", + ".sh": "shell", ".bash": "shell", +} + +const EDIT_TOOLS = new Set(["edit", "write", "apply_patch"]) +const SYNAPSE_URL = "https://synapse-coder-mcp-staging.greenbay-703e5a45.australiaeast.azurecontainerapps.io/mcp" +const QUEUE_PATH = ".opencode/synapse-coder-queue.json" +const PROMPT_MARKER_PATH = ".opencode/synapse-coder-prompted" +const MAX_QUEUE = 100 +const RETRY_INTERVAL_MS = 5 * 60 * 1000 + +interface ReportPayload { + tool: string + model: string + original: string + corrected: string + reason: string + reporterModel?: string + category?: string + language?: string +} + +interface PendingCorrection { + tool: string + model: string + language: string + original: string + reason: string + reporterModel: string + at: number +} + +// A failing edit is only reported once a follow-up edit to the same file in the +// same session lands with clean diagnostics — coder_report_correction requires a +// non-empty `corrected`, so one-sided detections are held (and dropped after the +// window) instead of polluting the learning corpus. +function pendingWindowMs(): number { + return Number(process.env.SYNAPSE_CODER_PENDING_WINDOW_MS) || 30 * 60 * 1000 +} + +function deriveLanguage(filePath: string): string { + const ext = filePath.match(/\.[^.]+$/)?.[0]?.toLowerCase() + return (ext && LANGUAGE_MAP[ext]) || "text" +} + +function extractOriginal(tool: string, args: any): string { + if (tool === "edit") return args?.newString ?? "" + if (tool === "write") return args?.content ?? "" + if (tool === "apply_patch") return args?.patchText ?? "" + return "" +} + +function extractFilePath(args: any): string { + return args?.filePath ?? args?.path ?? "" +} + +function hasDiagnostics(metadata: any): boolean { + const d = metadata?.diagnostics + if (!d) return false + if (Array.isArray(d)) return d.length > 0 + return Object.keys(d).length > 0 +} + +function formatDiagnostics(metadata: any): string { + return JSON.stringify(metadata?.diagnostics ?? {}) +} + +async function reportToSynapse(payload: ReportPayload): Promise { + const token = process.env.SYNAPSE_CODER_STAGING_BEARER_TOKEN + if (!token) return + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 10_000) + + try { + const body = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "coder_report_correction", + arguments: payload, + }, + id: Date.now(), + } + + const res = await fetch(SYNAPSE_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + signal: controller.signal, + }) + if (!res.ok) throw new Error(`synapse-coder responded ${res.status}`) + } finally { + clearTimeout(timeout) + } +} + +const plugin: Plugin = async (input) => { + const sessionModels = new Map() + const pending = new Map() + const queuePath = path.join(input.directory, QUEUE_PATH) + const promptMarkerPath = path.join(input.directory, PROMPT_MARKER_PATH) + let intervalHandle: ReturnType | undefined + let prompted = false + + // First-use opt-in prompt (AC-010): toast once per project when a correction is + // first detected while reporting is disabled. Headless (no TUI) skips the prompt + // and leaves the marker unwritten so a later TUI session still prompts. + async function promptOptInOnce(): Promise { + if (prompted) return + prompted = true + if (await Bun.file(promptMarkerPath).exists()) return + try { + await input.client.tui.showToast({ + body: { + title: "Synapse Coder learning loop", + message: "Correction detected. Reporting is off — set SYNAPSE_CODER_REPORTER_ENABLED=true to opt in.", + variant: "info", + }, + }) + await Bun.write(promptMarkerPath, new Date().toISOString()) + } catch { + prompted = false + } + } + + async function loadQueue(): Promise { + try { + const text = await Bun.file(queuePath).text() + const parsed = JSON.parse(text) + return Array.isArray(parsed) ? parsed.slice(-MAX_QUEUE) : [] + } catch { + return [] + } + } + + async function saveQueue(queue: ReportPayload[]): Promise { + await Bun.write(queuePath, JSON.stringify(queue.slice(-MAX_QUEUE), null, 2)) + } + + async function enqueue(payload: ReportPayload): Promise { + const queue = await loadQueue() + queue.push(payload) + await saveQueue(queue) + } + + let flushing = false + async function flushQueue(): Promise { + if (flushing) return + flushing = true + try { + const queue = await loadQueue() + if (queue.length === 0) return + const remaining: ReportPayload[] = [] + for (const payload of queue) { + try { + await reportToSynapse(payload) + } catch { + remaining.push(payload) + } + } + await saveQueue(remaining) + } finally { + flushing = false + } + } + + flushQueue().catch((err) => console.error("[synapse-coder-reporter] initial flush failed", err)) + intervalHandle = setInterval(() => { + flushQueue().catch((err) => console.error("[synapse-coder-reporter] flush failed", err)) + }, RETRY_INTERVAL_MS) + + return { + "chat.message": async (hookInput) => { + if (hookInput.model) { + sessionModels.set(hookInput.sessionID, `${hookInput.model.providerID}/${hookInput.model.modelID}`) + } else { + try { + const result = await input.client.session.get({ path: { id: hookInput.sessionID } }) + const session: any = (result as any)?.data ?? result + const modelId = session?.modelID ?? "unknown" + const providerId = session?.providerID ?? "unknown" + sessionModels.set(hookInput.sessionID, `${providerId}/${modelId}`) + } catch { + sessionModels.set(hookInput.sessionID, "unknown") + } + } + }, + + "tool.execute.after": async (hookInput, hookOutput) => { + if (!EDIT_TOOLS.has(hookInput.tool)) return + + const filePath = extractFilePath(hookInput.args) + const pendingKey = `${hookInput.sessionID}:${filePath}` + const reporterModel = sessionModels.get(hookInput.sessionID) ?? "unknown" + + if (hasDiagnostics(hookOutput.metadata)) { + const held: PendingCorrection = { + tool: hookInput.tool, + model: reporterModel, + language: deriveLanguage(filePath), + original: extractOriginal(hookInput.tool, hookInput.args), + reason: formatDiagnostics(hookOutput.metadata), + reporterModel, + at: Date.now(), + } + pending.set(pendingKey, held) + + if (process.env.SYNAPSE_CODER_REPORTER_ENABLED !== "true") { + console.log( + JSON.stringify({ + event: "synapse_correction_detected", + reported: false, + reason: "opt_in_disabled", + category: "lsp-typecheck", + language: held.language, + }), + ) + promptOptInOnce().catch((err) => console.error("[synapse-coder-reporter] opt-in prompt failed", err)) + return + } + + console.log( + JSON.stringify({ + event: "synapse_correction_detected", + reported: false, + reason: "awaiting_fix", + category: "lsp-typecheck", + language: held.language, + }), + ) + return + } + + const held = pending.get(pendingKey) + if (!held) return + pending.delete(pendingKey) + + if (process.env.SYNAPSE_CODER_REPORTER_ENABLED !== "true") return + + const corrected = extractOriginal(hookInput.tool, hookInput.args) + if (!corrected || corrected === held.original) return + if (Date.now() - held.at > pendingWindowMs()) { + console.log( + JSON.stringify({ + event: "synapse_correction_dropped", + reported: false, + reason: "pending_window_expired", + category: "lsp-typecheck", + language: held.language, + }), + ) + return + } + + const payload: ReportPayload = { + tool: held.tool, + model: held.model, + original: held.original, + corrected, + reason: held.reason, + reporterModel: held.reporterModel, + category: "lsp-typecheck", + language: held.language, + } + + Promise.resolve() + .then(() => reportToSynapse(payload)) + .then(() => + console.log( + JSON.stringify({ + event: "synapse_correction_reported", + reported: true, + category: payload.category, + language: payload.language, + }), + ), + ) + .catch((err) => { + console.error("[synapse-coder-reporter] report failed", err) + enqueue(payload).catch((e) => + console.error("[synapse-coder-reporter] enqueue failed", e), + ) + }) + }, + + dispose: async () => { + if (intervalHandle) clearInterval(intervalHandle) + await Promise.race([ + flushQueue().catch(() => {}), + new Promise((r) => setTimeout(() => r(), 5_000)), + ]) + }, + } +} + +export default plugin diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/INDEX.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/INDEX.md new file mode 100644 index 000000000000..325548597d46 --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/INDEX.md @@ -0,0 +1,122 @@ +# SYN-001: Synapse Coder Learning Loop Integration — Index + +**Feature ID:** SYN-001 +**Status:** Planning — awaiting review +**Branch:** `synapse-coder-reporter` +**Worktree:** `C:\GitHub\opencode---synapse-coder-reporter` + +## Plan Summary + +### Overview + +| Element | Value | +|---------|-------| +| Goal | Feed opencode's code-correction events into Synapse Coder's `coder_report_correction` learning loop to grow the shared lesson corpus | +| Core Insight | opencode already collects correction signals (LSP diagnostics in tool metadata, v1 plugin hooks); the integration is a plugin that hooks existing signals — no core changes | +| Approach | Plugin-only (Option A): MCP config + v1 plugin with `tool.execute.after` and `event` hooks | +| Why Not Option B (core changes) | Touches high-churn core files; violates fork-local "small, low-churn" constraint | +| Why Not Option C (LLM self-reports) | LLMs rarely self-report; poor signal quality; doesn't capture silent corrections | +| Scope - Included | MCP config, v1 plugin at `.opencode/plugin/`, LSP diagnostics detection (Signal 1), opt-in gate, offline queue, tests | +| Scope - Excluded | Permission rejection detection (Signal 2 — deferred; infeasible without core changes), core code changes, `experimental_repairToolCall` hook, format-on-write capture, thumbs-up/down UI | + +### Implementation Structure + +| Element | Count | +|---------|-------| +| Phases | 4 | +| Tasks | 12 total (2.3 deferred to Phase 2) | +| Waves | 4 | +| Review cycles | 1 per wave × 4 waves = 4 total | + +### Execution Strategy + +| Element | Value | +|---------|-------| +| Build Agents | 3-4 for parallel implementation tasks | +| Review Agents | 2-3 for code quality, security, integration | +| Review Rounds | 1 minimum per wave | +| Quality Gates | TypeScript 0 errors, tests all pass, fork-local compliance (no core diffs), no plaintext secrets, hook overhead < 5ms | + +### GitHub Tracking + +| Element | Value | +|---------|-------| +| Parent issue | (to be created) | +| Module issues | 1 (synapse-coder-reporter plugin) | +| Project tracking | Branch `synapse-coder-reporter` → PR → review → merge | +| Closeout rule | All acceptance criteria met, tests pass, fork-local diff verified, worktree cleaned up | + +### Standards Coverage + +| Concern | Planned Status | +|---------|----------------| +| Vision alignment | N/A — no VISION.md in opencode repo (fork-local add-on) | +| Documentation/work tracking | Full `current/` artifact set created | +| Realtime / SignalR | N/A — not a realtime feature | +| Versioning | N/A — plugin, not a shipped runtime artifact | +| Analytics/privacy | Opt-in gate (default off); no code leaves without consent; structured logging | +| Accessibility | N/A — plugin has no UI beyond a TUI toast | +| Performance | Async fire-and-forget; hook overhead < 5ms | +| LLM observability | N/A — not an LLM feature; feeds Synapse Coder's learning loop | +| Hosted adversarial review | N/A — Synapse Coder runs on Azure Container Apps (staging facade), but this integration is a passive consumer of its MCP API, not a deployment to GpuAsAService. Adversarial review deferred — the integration does not deploy or modify GpuAsAService infrastructure | + +### Top 3 Risks + +| Risk | Mitigation | +|------|------------| +| User sends sensitive client code without realizing | Opt-in default off; first-use TUI toast; structured logging; disable anytime | +| Synapse Coder staging facade down | Health check on load; graceful degradation; offline queue | +| Plugin hook overhead slows tool execution | Async fire-and-forget; no `await` in hook path; benchmark < 5ms | + +### Uncertainties + +- Whether Synapse Coder accepts one-sided corrections (original without corrected) — verify in Phase 1 Task 1.2 +- Whether `tool.execute.after` fires for MCP tools as well as built-in tools — verify in Phase 1 Task 1.2 +- Exact `coder_report_correction` response shape — verify in Phase 1 + +### Expected Outcome If Implemented + +After implementation, opencode sessions that produce LSP errors after edits, or where users reject edits with feedback, will automatically (with user opt-in) report the original code and the correction to Synapse Coder's learning loop. Over time, this grows the shared lesson corpus, making Synapse Coder (and by extension all Alterspective AI coding tools) better at avoiding the same mistakes. The user sees a small indicator when a correction is being reported and can disable it at any time. + +**Minimum coverage:** +- What will exist: a `synapse-coder-reporter` plugin and MCP config that passively detects and reports code corrections +- What the user can do: enable/disable reporting via config; see when corrections are detected; benefit from improved Synapse Coder lessons over time +- Limitations: one-sided corrections (no next-turn fix pairing); can't capture format-on-write or malformed-tool-call corrections without Phase 2 core changes + +### Further Information Needed From User + +No further information needed from user at this stage. The plan is ready for review. + +### Quality Validation + +| Check | Status | +|-------|--------| +| All GATE PASSED tokens emitted | YES | +| All checklist items complete | YES | + +### Ready to Proceed? + +YES — ready for plan review, then execution with `Skills/Lifecycle/LIF-001-21-planning-execute-plan.md`. + +### Suggested Next Step + +1. Spawn a review sub-agent to review the plan against the `LIF-001-20-planning-create-plan` completion checklist +2. Address any review feedback +3. Execute via `21-planning-execute-plan` with multi-agent coordination (4 waves, 13 tasks) + +## Document Index + +| Document | Purpose | +|----------|---------| +| [README.md](README.md) | Feature overview | +| [requirements.md](requirements.md) | Requirements, assumptions, standards | +| [technical-design.md](technical-design.md) | Architecture, hook points, options analysis | +| [impact-analysis.md](impact-analysis.md) | Affected files, risk assessment | +| [acceptance-criteria.md](acceptance-criteria.md) | Acceptance criteria and test scenarios | +| [checklist.md](checklist.md) | Task breakdown by phase | +| [status.md](status.md) | Current execution status | +| [module-register.md](module-register.md) | Module registry and dependencies | +| [issues.md](issues.md) | Open issues and blockers | +| [ai-memory.md](ai-memory.md) | Key decisions and gotchas | +| [ai-handover.md](ai-handover.md) | Session continuity | +| [evidence/investigation-findings.md](evidence/investigation-findings.md) | Full codebase investigation report | diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/README.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/README.md new file mode 100644 index 000000000000..bfc17001b974 --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/README.md @@ -0,0 +1,75 @@ +# SYN-001: Synapse Coder Learning Loop Integration + +**Feature ID:** SYN-001 +**Status:** Planning +**Created:** 2026-07-18 +**Owner:** Igor Jericevich +**Branch:** `synapse-coder-reporter` +**Worktree:** `C:\GitHub\opencode---synapse-coder-reporter` + +## Summary + +Feed opencode's code-correction events into Synapse Coder's `coder_report_correction` learning loop. When opencode observes a correction (LSP diagnostics after an edit, a user rejecting an edit with feedback), the integration reports the original and corrected code to Synapse Coder, growing the shared lesson corpus and improving future AI coding assistance across Alterspective. + +## Core Insight + +opencode already collects the key correction signals — LSP diagnostics are attached to tool result metadata (`packages/opencode/src/tool/edit.ts:203-208`), and the v1 plugin system has `tool.execute.after` and `event` hooks (`packages/plugin/src/index.ts:274-281`, `224`) that fire on every tool execution and event. The integration is a v1 plugin that hooks these existing signals and calls the Synapse Coder MCP tool. **No core code changes required for Tier 1 signals.** + +## Approach + +Plugin-only integration (Option A): +1. Configure Synapse Coder as a remote MCP server in `opencode.json` (staging facade + bearer token from vault) +2. Write a v1 plugin (`synapse-coder-reporter`) that hooks `tool.execute.after` and `event` +3. Detect corrections: non-empty LSP diagnostics after edits, permission rejections with feedback +4. Call `coder_report_correction` MCP tool with original/corrected/category/language/reason/reporterModel +5. User opt-in gate; graceful degradation when Synapse is unreachable + +## Why Not Alternatives + +- **Option B (core code changes + plugin):** Touches high-churn core files (`edit.ts`, `write.ts`, `llm.ts`) that conflict with upstream merges. The fork-local note in `AGENTS.md` explicitly requires "small and in low-churn files." Rejected. +- **Option C (custom tool, LLM self-reports):** LLMs rarely self-report corrections; signal quality is poor. Doesn't capture silent corrections. Rejected. + +## Scope + +**Included:** +- Synapse Coder MCP server config in `opencode.json` +- v1 plugin (`synapse-coder-reporter`) at `.opencode/plugin/synapse-coder-reporter.ts` with `tool.execute.after` and `chat.message` hooks +- Correction detection: LSP diagnostics after edits (Signal 1 — primary) +- User consent gate (opt-in setting, default off) +- Error handling and offline queue +- Unit and integration tests + +**Excluded (Phase 2, deferred):** +- Permission rejection with feedback detection (Signal 2 — infeasible in Phase 1: `permission.v2.replied` event doesn't carry feedback text; `tool.execute.after` doesn't fire on errors) +- Core code changes to capture pre-format LLM literal output +- New plugin hook for `experimental_repairToolCall` corrections +- Format-on-write before/after capture (requires core changes) +- Thumbs-up/down UI (greenfield, separate feature) + +## Documents + +| Document | Purpose | +|----------|---------| +| [requirements.md](requirements.md) | What we're building and why | +| [technical-design.md](technical-design.md) | Architecture, hook points, options analysis | +| [impact-analysis.md](impact-analysis.md) | Affected files, risk assessment | +| [acceptance-criteria.md](acceptance-criteria.md) | How we know it's done | +| [checklist.md](checklist.md) | Task breakdown | +| [status.md](status.md) | Current execution status | +| [module-register.md](module-register.md) | Module registry | +| [issues.md](issues.md) | Open issues and blockers | +| [ai-memory.md](ai-memory.md) | Key decisions and gotchas | +| [ai-handover.md](ai-handover.md) | Session continuity | + +## Investigation Evidence + +Full codebase investigation findings are in `evidence/investigation-findings.md`. Key hook points: + +| Hook Point | File:Line | Signal | +|------------|-----------|--------| +| LSP diagnostics after edit | `packages/opencode/src/tool/edit.ts:197-201` | Non-empty `metadata.diagnostics` | +| LSP diagnostics after write | `packages/opencode/src/tool/write.ts:75-90` | Non-empty `metadata.diagnostics` | +| `tool.execute.after` plugin hook | `packages/plugin/src/index.ts:274-281` | Receives `output.metadata` | +| `event` plugin hook | `packages/plugin/src/index.ts:224` | Receives all events | +| Permission rejection | `packages/opencode/src/permission/index.ts:125` | `CorrectedError.feedback` | +| Model ID available | `packages/opencode/src/session/llm.ts:40` | `input.model.providerID/id` | diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/acceptance-criteria.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/acceptance-criteria.md new file mode 100644 index 000000000000..3bcbcccd95fa --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/acceptance-criteria.md @@ -0,0 +1,37 @@ +# SYN-001: Acceptance Criteria + +## Functional Acceptance Criteria + +| ID | Criterion | Verification Method | +|----|-----------|-------------------| +| AC-001 | Synapse Coder MCP server is configured in `opencode.json` and connects on opencode startup | `opencode mcp list` shows `synapse-coder` with status `connected`; `coder_report_correction` appears in tool list | +| AC-002 | The `synapse-coder-reporter` plugin loads without errors | opencode startup log shows plugin loaded; no console errors | +| AC-003 | When the LLM uses `edit`/`write`/`apply_patch` and LSP reports diagnostics, the plugin detects the correction | Structured log: `{ event: "correction_detected", signal: "lsp_diagnostics", file, category: "lsp-typecheck" }` | +| AC-004 | (DEFERRED) Permission rejection detection — Phase 2 | N/A in Phase 1 | +| AC-005 | When opt-in is enabled, the plugin calls `coder_report_correction` with the correct payload | Synapse Coder staging logs show the report; payload matches: `reporterModel`, `category`, `language`, `original`, `corrected`, `reason` | +| AC-006 | When opt-in is disabled (default), no code is sent to Synapse | No network calls to Synapse; structured log: `{ event: "correction_detected", reported: false, reason: "opt_in_disabled" }` | +| AC-007 | When Synapse Coder is unreachable, the plugin queues the report and does not crash | Offline queue file `.opencode/synapse-coder-queue.json` has pending entries; opencode continues normally | +| AC-008 | The `reporterModel` field is correctly populated with the current model ID | Report payload shows `reporterModel: "anthropic/claude-sonnet-4-5"` (or current model) | +| AC-009 | The `language` field is correctly derived from the file extension | Report payload shows `language: "typescript"` for `.ts` files, `language: "python"` for `.py` files | +| AC-010 | First-use opt-in prompt appears when a correction is first detected | TUI toast: "Synapse Coder learning loop detected a correction. Enable reporting?" | + +## Non-Functional Acceptance Criteria + +| ID | Criterion | Verification Method | +|----|-----------|-------------------| +| NAC-001 | Plugin hook overhead is < 5ms per tool call (non-blocking) | Benchmark: time `tool.execute.after` hook with and without plugin; measure with `performance.now()` | +| NAC-002 | No core code changes (fork-local compliance) | `git diff dev..synapse-coder-reporter -- packages/opencode/src/tool/ packages/opencode/src/session/ packages/opencode/src/lsp/` returns empty | +| NAC-003 | No committed plaintext credentials | `grep -r "Bearer" opencode.json` returns only `{env:SYNAPSE_CODER_STAGING_BEARER_TOKEN}` env var reference (opencode's `{env:VAR}` syntax, not `${VAR}`) | +| NAC-004 | All tests pass (`bun test` from `packages/opencode`) | Test run output: all green, 0 failures | +| NAC-005 | TypeScript checks pass (`bun typecheck` from `packages/opencode`) | Typecheck output: 0 errors | + +## Test Scenarios + +| Scenario | Steps | Expected | +|----------|-------|----------| +| TS-001: LSP diagnostics after edit | 1. Enable opt-in
2. Ask LLM to edit a `.ts` file with a type error
3. LSP reports diagnostics | Plugin detects correction; `coder_report_correction` called with `category: "lsp-typecheck"`, `language: "typescript"` | +| TS-002: Permission rejection (DEFERRED) | Deferred to Phase 2 — not feasible in Phase 1 | N/A | +| TS-003: Opt-in disabled | 1. Disable opt-in
2. Trigger LSP diagnostics | No network call to Synapse; correction logged as `reported: false` | +| TS-004: Synapse unreachable | 1. Enable opt-in
2. Block the Synapse URL (or use invalid URL)
3. Trigger a correction | Report queued in `.opencode/synapse-coder-queue.json`; opencode continues normally | +| TS-005: Model ID tracking | 1. Switch model to e.g. `openai/gpt-5`
2. Trigger a correction | `reporterModel: "openai/gpt-5"` in the report payload | +| TS-006: Language derivation | 1. Edit a `.py` file with a type error
2. Check report payload | `language: "python"` | diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/ai-handover.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/ai-handover.md new file mode 100644 index 000000000000..76c9d19e8566 --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/ai-handover.md @@ -0,0 +1,50 @@ +# SYN-001: AI Handover + +**Session:** 2026-07-18 +**Status:** Plan created, awaiting review → then execution +**Next step:** Plan review by sub-agent, then execution via `21-planning-execute-plan` skill + +## What was done this session + +1. **Repo entry safety check** — clean on `dev`, no active worktrees (R3 evidence: `git branch --show-current` = `dev`, `git status --short` = empty, `git worktree list` = only primary) +2. **Codebase investigation** — delegated to explore agent; full findings in `evidence/investigation-findings.md`. Key hook points identified with file:line citations. +3. **Worktree created** — `C:\GitHub\opencode---synapse-coder-reporter` on branch `synapse-coder-reporter` (AIMETH-005 compliance) +4. **Plan documents created** — full `current/` artifact set under `docs/implementation/current/SYN-001-synapse-coder-reporter/` + +## What needs to happen next + +1. **Plan review** — a fresh sub-agent reviews the plan for completeness, feasibility, and standards compliance. Review checklist: + - [ ] All completion-verification items from `LIF-001-20-planning-create-plan` satisfied + - [ ] Assumptions have evidence (R3) + - [ ] Options presented with trade-offs + - [ ] Devil's advocate completed before recommendation + - [ ] >= 3 failure modes in pre-mortem + - [ ] Every task has Deliverable + Verification method + - [ ] >= 4 risks across >= 3 categories + - [ ] No core code changes (fork-local compliance) + +2. **Address review feedback** — fix any issues found by the reviewer + +3. **Execute the plan** — using `21-planning-execute-plan` skill with multi-agent coordination: + - Wave 1: Tasks 1.1, 1.2 (foundation + MCP wiring) + - Wave 2: Tasks 2.1–2.4 (correction detection plugin, parallel) + - Wave 3: Tasks 3.1–3.4 (reporting + consent, parallel) + - Wave 4: Tasks 4.1–4.3 (testing + verification, parallel) + +## Key context for the next session + +- **Branch:** `synapse-coder-reporter` in worktree `C:\GitHub\opencode---synapse-coder-reporter` +- **Plan location:** `docs/implementation/current/SYN-001-synapse-coder-reporter/` +- **The plugin approach is non-negotiable** — no core code changes to `edit.ts`, `write.ts`, `llm.ts`, `processor.ts`, `tools.ts` +- **Synapse Coder staging facade:** `https://synapse-coder-mcp-staging.greenbay-703e5a45.australiaeast.azurecontainerapps.io/mcp` +- **Bearer token:** vault secret `synapse-coder-mcp-staging-bearer-token` +- **opencode does NOT use `.mcp.json`** — use `opencode.json` → `mcp` key +- **The v1 plugin `Hooks` interface is live and stable** (`packages/plugin/src/index.ts:222-335`) + +## Validation gates (must pass before merge) + +- `bun typecheck` from `packages/opencode` — 0 errors +- `bun test` from `packages/opencode` — all pass +- `git diff dev..synapse-coder-reporter -- packages/opencode/src/tool/ packages/opencode/src/session/ packages/opencode/src/lsp/` — empty (fork-local) +- No plaintext secrets in `opencode.json` +- Plugin hook overhead < 5ms per tool call diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/ai-memory.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/ai-memory.md new file mode 100644 index 000000000000..8b66202f8e30 --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/ai-memory.md @@ -0,0 +1,47 @@ +# SYN-001: AI Memory + +Key decisions, gotchas, and context for AI continuity. + +## Key Decisions + +| Decision | Rationale | Date | +|----------|-----------|------| +| Plugin-only approach (no core code changes) | Fork-local constraint: "keep every local change small and in low-churn files so `git fetch upstream && git merge upstream/dev` stays clean" | 2026-07-18 | +| Use Synapse Coder staging facade (not prod) | Validate integration before prod; staging is the sanctioned testing surface | 2026-07-18 | +| Opt-in default off | Privacy: no code leaves the machine without explicit user consent | 2026-07-18 | +| One-sided corrections for LSP diagnostics | The next-turn fix is not available in the `tool.execute.after` hook; Synapse may accept one-sided reports with `reason` containing the diagnostics | 2026-07-18 | +| `chat.message` hook for model tracking | The `tool.execute.after` hook doesn't receive the model ID directly; `chat.message` (`packages/plugin/src/index.ts:234-243`) carries `model: { providerID, modelID }` (optional) and can populate a per-session map | 2026-07-18 | +| Signal 2 (permission rejection) DEFERRED to Phase 2 | Plan review found `permission.v2.replied` event doesn't carry feedback text (`packages/schema/src/permission.ts:44-51`); `tool.execute.after` doesn't fire on errors (`packages/opencode/src/session/tools.ts:111`). Not feasible without core changes | 2026-07-18 | +| Bearer token syntax: `{env:VAR}` not `${VAR}` | Plan review found opencode uses `{env:VAR}` substitution (`packages/opencode/src/config/variable.ts:36`), not shell-style `${VAR}` | 2026-07-18 | +| Plugin at `.opencode/plugin/` not `packages/` | Plan review: `.opencode/plugin/` is outside `packages/` — zero merge risk with upstream; auto-discovered via `packages/opencode/src/config/plugin.ts:18-30` | 2026-07-18 | +| Pairing redesign: hold failing edits, report only when a same-file follow-up edit lands clean | E2E found the live `coder_report_correction` schema requires `tool`, `model`, and **non-empty** `original`/`corrected` (`additionalProperties: false`) — one-sided reports are rejected. Placeholder `corrected` values would pollute the learning corpus, so detection and reporting are decoupled | 2026-07-19 | +| Env-var opt-in gate instead of config schema key | `synapse_coder.enabled` would require a core config schema change (fork-local violation). `SYNAPSE_CODER_REPORTER_ENABLED` (default off) + first-use TUI toast with once-per-project marker `.opencode/synapse-coder-prompted` satisfies AC-006/AC-010 without core changes | 2026-07-19 | + +## Gotchas + +| Gotcha | Impact | Mitigation | +|--------|--------|-----------| +| opencode does NOT use `.mcp.json` — it uses `opencode.json` → `mcp` key | MCP config won't load if put in `.mcp.json` | Use `opencode.json`; evidence: `packages/core/src/v1/config/config.ts:113-115`, `packages/opencode/src/cli/cmd/mcp.ts:394-410` | +| Bearer token syntax is `{env:VAR}` NOT `${VAR}` | Auth fails with 401 if wrong syntax used | Use `Bearer {env:SYNAPSE_CODER_STAGING_BEARER_TOKEN}`; evidence: `packages/opencode/src/config/variable.ts:36` (regex `/\{env:([^}]+)\}/g`) | +| `permission.v2.replied` event doesn't carry feedback text | Signal 2 (permission rejection) is infeasible without core changes | Deferred to Phase 2; evidence: `packages/schema/src/permission.ts:44-51` (schema has only `sessionID`, `requestID`, `reply`) | +| `tool.execute.after` doesn't fire when tool throws | CorrectedError (permission rejection) prevents the hook trigger from running | Signal 2 deferred; evidence: `packages/opencode/src/session/tools.ts:111` (generator fails before reaching trigger at line 121) | +| `chat.message` hook `model` field is optional | `reporterModel` may be undefined when user doesn't specify a model | Fallback: query `client.session.get(sessionID)` or default to `"unknown"`; evidence: `packages/plugin/src/index.ts:238` (`model?:`) | +| TUI event is `tui.toast.show` not `TuiEvent.ToastShow` | Event type names are lowercase dot-notation, not PascalCase | Use `client.tui.showToast()`; evidence: `packages/schema/src/tui-event.ts:40-41` | +| `edit.ts` discards the pre-format LLM literal output after `format.file()` runs | Can't capture "LLM proposed X, formatter corrected to Y" pair without core changes | Documented as Phase 2 (deferred); the `tool.execute.after` hook still has `input.args.newString` as the original | +| `experimental_repairToolCall` is not plugin-hookable | Can't capture malformed-tool-call corrections without core changes | Documented as Phase 2 (deferred) | +| v2 plugin system is in progress but not wired into live session loop | v1 hooks are stable for now, but may be deprecated when v2 lands | Monitor v2 progress; plugin can be migrated when v2 lands | +| The `write.ts` tool does NOT return `diff` in metadata (only in the permission ask) | Asymmetry vs `edit.ts` for diff extraction | Use `input.args.content` as the original for write tool | +| Bearer token must come from vault, not hardcoded | `WEBSTA-001-SECRETS-MANAGEMENT` violation if committed | Use `${SYNAPSE_CODER_STAGING_BEARER_TOKEN}` env var in `opencode.json`; resolve from vault `synapse-coder-mcp-staging-bearer-token` | +| **Live `coder_report_correction` schema differs from docs** | Payloads matching the global agent doc are rejected with MCP -32602 | Required: `tool`, `model`, `original`, `corrected` (all minLength 1). Optional: `reason`, `reporterModel`, `category` (kebab), `severity`, `language`, `taskFamily`, `checkPattern`. `additionalProperties: false`. Verify with live `tools/list` before changing the payload builder | +| `mcp list` shows "connected" but that does not validate tool argument schemas | A green MCP connection can still hide a broken payload contract | Always E2E the actual `tools/call` — unit tests with mocked fetch cannot catch schema drift (ISS-001) | +| Local MCP config shape is `command: string[]` + `environment` + `enabled`, not `command`+`args`+`env` | Config validation fails at startup (`packages/core/src/v1/config/mcp.ts:6-23`) | See ISS-004 | +| `bun test` on Windows intermittently reports "beforeEach/afterEach hook timed out (~30s+)" under load | False-failure noise; single- and multi-file | Environmental, no deterministic repro. Repo test script's `--only-failures` rerun handles it. See `evidence/phase4-e2e-verification.md` | + +## Context for Continuation + +- **Worktree:** `C:\GitHub\opencode---synapse-coder-reporter` on branch `synapse-coder-reporter` +- **Repo entry check:** Clean on `dev`, no active worktrees (other than this one) +- **Investigation findings:** Full report in `evidence/investigation-findings.md` (delegated to explore agent, task `ses_08af21ebcffej8PIDqksXqeYgs`) +- **Planning skill:** `Skills/Lifecycle/LIF-001-20-planning-create-plan.md` (loaded via RAG) +- **Synapse Coder details:** `C:\GitHub\AGENTS.md` Synapse Coder section; `Reference/AI/Capabilities/KB-AI-016-MCP-Server-Reference.md` +- **opencode version:** 1.18.3 diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/checklist.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/checklist.md new file mode 100644 index 000000000000..2446cb4403ea --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/checklist.md @@ -0,0 +1,45 @@ +# SYN-001: Task Checklist + +## Phase 1: Foundation & MCP Wiring + +| Task | Description | Agent | Deliverable | Verification | +|------|-------------|-------|-------------|--------------| +| 1.1 | Add Synapse Coder MCP server config to `opencode.json` (using `{env:SYNAPSE_CODER_STAGING_BEARER_TOKEN}` syntax, NOT `${VAR}`) | Build | `opencode.json` has `mcp.synapse-coder` entry with staging URL and `Bearer {env:SYNAPSE_CODER_STAGING_BEARER_TOKEN}` header | `opencode mcp list` shows `synapse-coder` connected; `coder_report_correction` in tool list | +| 1.2 | Verify MCP tool discovery and plugin hook firing | Build | Evidence file `evidence/phase1-mcp-verification.md` with command output | `coder_report_correction` callable; `tool.execute.after` hook fires on a test edit | + +## Phase 2: Correction Detection Plugin + +| Task | Description | Agent | Deliverable | Verification | +|------|-------------|-------|-------------|--------------| +| 2.1 | Create plugin scaffold at `.opencode/plugin/synapse-coder-reporter.ts` with `chat.message` hook for model tracking (with fallback to `client.session.get()` or `"unknown"`) | Build | Plugin file at `.opencode/plugin/synapse-coder-reporter.ts` | Plugin loads; model map populated per session | +| 2.2 | Implement `tool.execute.after` hook for LSP diagnostics detection (edit/write/apply_patch) | Build | Hook registered; detection logic for non-empty `metadata.diagnostics` | Structured log on correction detection (TS-001) | +| 2.3 | (DEFERRED to Phase 2) Permission rejection detection — infeasible in Phase 1 (`permission.v2.replied` doesn't carry feedback; `tool.execute.after` doesn't fire on errors) | — | — | — | +| 2.4 | Implement language derivation from file extension | Build | Language map utility; unit tests for all extensions in the map | `language` field correct in report payload (TS-006) | + +## Phase 3: Reporting & Consent + +| Task | Description | Agent | Deliverable | Verification | +|------|-------------|-------|-------------|--------------| +| 3.1 | Implement reporter module — call `coder_report_correction` MCP tool via opencode client | Build | Reporter function; payload builder with all required fields | Report payload matches schema (AC-005) | +| 3.2 | Implement user opt-in gate (config + first-use TUI toast) | Build | Config schema `synapse_coder.enabled`; `TuiEvent.ToastShow` on first correction | Opt-in disabled by default (AC-006); first-use prompt appears (AC-010) | +| 3.3 | Implement error handling and offline queue | Build | Queue file `.opencode/synapse-coder-queue.json`; retry on plugin load + 5-min timer | Synapse unreachable → queue grows; opencode continues (AC-007, TS-004) | +| 3.4 | Implement async fire-and-forget (non-blocking hooks) | Build | All MCP calls wrapped in `Promise.resolve().then(...)` or equivalent; no `await` in hook path | Hook overhead < 5ms (NAC-001) | + +## Phase 4: Testing & Verification + +| Task | Description | Agent | Deliverable | Verification | +|------|-------------|-------|-------------|--------------| +| 4.1 | Unit tests for each correction detector + language map | Testing | Test files in `packages/opencode/test/plugin/synapse-coder-reporter/` | All unit tests pass (NAC-004) | +| 4.2 | Integration test — simulate LSP error after edit, verify report fires | Testing | Test file with mock LSP diagnostics; verify MCP tool called | Report payload correct (TS-001) | +| 4.3 | E2E verification — run real opencode session, verify Synapse receives correction | Testing | Evidence file `evidence/phase4-e2e-verification.md` with screenshots/logs | Synapse staging logs show the report (AC-005) | + +## Wave Structure + +| Wave | Tasks | Type | Review | +|------|-------|------|--------| +| 1 | 1.1, 1.2 | Build + Verify | Review after wave | +| 2 | 2.1, 2.2, 2.4 | Build (parallel) | Review after wave | +| 3 | 3.1, 3.2, 3.3, 3.4 | Build (parallel) | Review after wave | +| 4 | 4.1, 4.2, 4.3 | Testing (parallel) | Review after wave | + +Total: 12 tasks (2.3 deferred), 4 waves, 4 review cycles. diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/evidence/investigation-findings.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/evidence/investigation-findings.md new file mode 100644 index 000000000000..fa8e90d558b5 --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/evidence/investigation-findings.md @@ -0,0 +1,169 @@ +# Investigation Findings: opencode Codebase Hook Points for Synapse Coder Integration + +**Investigation date:** 2026-07-18 +**Investigator:** explore agent (task `ses_08af21ebcffej8PIDqksXqeYgs`) +**Repo:** `C:\GitHub\opencode`, branch `dev`, commit `a90c2b8557` +**Method:** Read-only investigation — no files modified + +## Architectural Overview + +opencode is built on **Effect v4 services** with Layer-based dependency injection. Per-project/per-directory state uses `InstanceState` (a `ScopedCache` keyed by directory). + +- Module shape: flat top-level exports + self-reexport (`export * as Foo from "./foo"`), per `packages/opencode/AGENTS.md` +- The codebase has a **v1 path** (live, in `packages/opencode/src/`) and an in-progress **v2 path** (in `packages/core/src/session/`). All findings are about the v1 path. + +### Package structure + +| Package | Role | +|---------|------| +| `packages/opencode` | Live CLI/TUI/server — session loop, tools, MCP, plugins, LSP, format, snapshot, provider | +| `packages/core` | Shared primitives, v1 schemas, v2 session core (in progress) | +| `packages/plugin` | Plugin SDK — v1 `Hooks` interface + v2 Effect-based plugin system (being built) | +| `packages/schema` | Event manifest catalog | +| `packages/tui` | Terminal UI | +| `packages/llm` | LLM event types | + +### Main session loop + +- `packages/opencode/src/session/prompt.ts:1081` — `runLoop(sessionID)` drives the conversation +- `packages/opencode/src/session/processor.ts:627` — `process(streamInput)` runs the LLM stream +- `packages/opencode/src/session/llm.ts:86` — `LLM.run` builds the `streamText({...})` call + +## Hook Points for Correction Reporting + +### Tier 1 — Highest-value, already-collected signals + +#### 1. LSP diagnostics after Edit/Write/apply_patch + +After every file mutation, opencode calls `lsp.touchFile` + `lsp.diagnostics`, filters to severity 1 (ERROR), and appends to the tool output. The diagnostics are returned in `metadata`. + +- `packages/opencode/src/tool/edit.ts:197-201` — LSP touch + diagnostics + block appended to output +- `packages/opencode/src/tool/edit.ts:203-208` — returns `metadata: { diagnostics, diff, filediff }` +- `packages/opencode/src/tool/write.ts:75-90` — same pattern; also surfaces cross-file diagnostics +- `packages/opencode/src/tool/apply_patch.ts:265-293` — same pattern for multi-file patches + +**Signal:** `metadata.diagnostics` non-empty → correction detected. `original = args.newString`/`args.content`/`args.patchText`; `category = "lsp-typecheck"`. + +#### 2. `tool.execute.after` plugin hook + +- `packages/plugin/src/index.ts:274-281` — fires after every tool execution +- Receives `input: { tool, sessionID, callID, args }` and `output: { title, output, metadata }` +- This is the canonical hook for consuming tool results + +**Signal:** Check `output.metadata?.diagnostics` when `input.tool` is `edit`/`write`/`apply_patch`. + +#### 3. `event` plugin hook + +- `packages/plugin/src/index.ts:224` — fires on ANY event published +- Can observe `Session.Event.Error`, `FileSystem.Event.Edited`, `Permission.Event.Replied` + +**Signal:** Listen for permission rejection events to capture `CorrectedError.feedback`. + +### Tier 2 — User-driven corrections + +#### 4. Permission rejection with feedback + +- `packages/opencode/src/permission/index.ts:125` — constructs `PermissionV1.CorrectedError({ feedback: input.message })` +- The user's rejection message is a correction signal + +**Signal:** `category = "user-rejection"`; `reason = feedback message`. + +#### 5. `chat.message` plugin hook + +- `packages/plugin/src/index.ts:234-243` — fires on every user message +- Carries `model: { providerID, modelID }` — useful for `reporterModel` tracking + +### Tier 3 — Historical/reconstructive (not used in Phase 1) + +#### 6. Snapshot diffFull + +- `packages/opencode/src/snapshot/index.ts:546-759` — reconstruct before/after for any step historically +- Useful for batch backfill, not real-time feeding + +#### 7. Patch parts in transcript + +- `packages/opencode/src/session/processor.ts:457-468` — per-step file-change sets + +## MCP Client Integration + +opencode has a full MCP client. **Critical:** opencode does NOT use `.mcp.json` — it uses `opencode.json` → `mcp` key. + +- Config: `packages/core/src/v1/config/config.ts:113-115` — `mcp` schema +- Config shape: `packages/core/src/v1/config/mcp.ts:1` — `ConfigMCPV1.Info` (`type: "local" | "remote"`, `url`, `headers`, `env`, `oauth`, `timeout`, `enabled`) +- MCP service: `packages/opencode/src/mcp/index.ts` — `MCP.Service` (1004 lines) + - `connectRemote` (line 236-338): `StreamableHTTPClientTransport` with OAuth/headers + - `tools()` (line 666-688): returns `Record` +- Tool conversion: `packages/opencode/src/mcp/catalog.ts:42-83` — wraps MCP tool as AI SDK `dynamicTool` +- MCP auth: `packages/opencode/src/mcp/auth.ts`, `oauth-provider.ts`, `oauth-callback.ts` + +## Plugin Architecture (v1 — live and stable) + +- `packages/plugin/src/index.ts:222-335` — the `Hooks` interface +- Plugin loader: `packages/opencode/src/plugin/loader.ts` — `PluginLoader.loadExternal` from `cfg.plugin_origins` +- Plugin service: `packages/opencode/src/plugin/index.ts:123-306` — loads internal + external plugins +- Plugin input: `packages/plugin/src/index.ts:56-66` — `client`, `project`, `directory`, `worktree`, `$` (BunShell) +- Plugin-supplied tools: `packages/opencode/src/tool/registry.ts:194-199` — via `tool` hook +- Custom tools from project files: `packages/opencode/src/tool/registry.ts:178-192` — `{tool,tools}/*.{js,ts}` in config dirs + +### Full hook catalogue + +| Hook | Line | Synapse Coder relevance | +|------|------|-------------------------| +| `tool.execute.before` | 266-269 | Capture the LLM's intended args (the `original`) | +| `tool.execute.after` | 274-281 | **Capture result + metadata (diff, diagnostics)** | +| `event` | 224 | Observe permission rejections, errors | +| `chat.message` | 234-243 | Track model ID per session (for `reporterModel`) | +| `experimental.text.complete` | 327-330 | LLM text rewrites (if plugin rewrites) | + +## Provider/Model Configuration + +The `reporterModel` parameter needs the current model ID. opencode tracks this at: + +- `packages/opencode/src/session/llm.ts:40` — `LLM.StreamInput.model: Provider.Model` +- `packages/opencode/src/provider/provider.ts:1031-1046` — `Provider.Model` schema with `id`, `providerID` +- `packages/opencode/src/session/prompt.ts:1186-1200` — assistant message stores `modelID`/`providerID` +- `packages/plugin/src/index.ts:234-243` — `chat.message` hook carries `model: { providerID, modelID }` + +**Format:** `${input.model.providerID}/${input.model.id}` (e.g. `anthropic/claude-sonnet-4-5`, `openrouter/z-ai/glm-5.2`) + +## File-Edit Application Logic + +Three file-mutation tools, all following the same pattern: + +### Edit tool (`packages/opencode/src/tool/edit.ts`) +- Line 86-87: `contentOld`, `contentNew` declared +- Line 126-127: reads current file +- Line 133: `replace(contentOld, old, replacement, params.replaceAll)` — fuzzy matching (9 replacers) +- Line 137-144: `diff = createTwoFilesPatch(...)` +- Line 155: `afs.writeWithDirs(filePath, ...)` — **the file mutation** +- Line 156-158: formatter runs, `contentNew` re-read from disk — **silent correction point** +- Line 197-201: LSP diagnostics collected +- Line 203-208: returns `metadata: { diagnostics, diff, filediff }` + +### Write tool (`packages/opencode/src/tool/write.ts`) +- Line 50-53: `contentOld`/`contentNew`/`diff` computed +- Line 64: file mutation +- Line 65-67: formatter runs +- Line 75-90: LSP diagnostics +- Line 92-100: returns `metadata: { diagnostics, filepath, exists }` (no `diff` in metadata) + +### apply_patch tool (`packages/opencode/src/tool/apply_patch.ts`) +- Line 58-68: `fileChanges` array +- Line 220-258: applies changes per file, runs formatter per file +- Line 265-293: LSP per file +- Line 295-302: returns `metadata: { diff: totalDiff, files, diagnostics }` + +## Key Gaps (documented in plan as Phase 2, deferred) + +- **Format-on-write discards pre-format text** — `edit.ts:112-114` re-reads `contentNew` after formatting but the LLM's literal `params.newString` is not retained +- **`experimental_repairToolCall` not plugin-hookable** — `llm.ts:297-313`; would need a code change to expose +- **No existing feedback UI** — no thumbs-up/down or rating mechanism +- **v2 session core in progress** — `packages/core/src/file-mutation.ts:201-207` has explicit TODOs + +## Summary: Best Hook Points (ranked) + +1. **`tool.execute.after` plugin hook** (Tier 1) — metadata already has diagnostics; `input.args` has the original LLM input +2. **`event` plugin hook** (Tier 2) — for permission rejections with feedback +3. **`chat.message` plugin hook** (Tier 1) — for model ID tracking (reporterModel) + +**Integration vehicle:** MCP server config in `opencode.json` + v1 plugin loaded from `plugin_origins`. No core code changes needed. diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/evidence/phase1-mcp-verification.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/evidence/phase1-mcp-verification.md new file mode 100644 index 000000000000..cfdbf6e376ec --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/evidence/phase1-mcp-verification.md @@ -0,0 +1,69 @@ +# Phase 1 Evidence: MCP Wiring Verification (Task 1.2) + +**Date:** 2026-07-19 +**Verifier:** AI agent (resume session) +**Branch:** `synapse-coder-reporter` (worktree `C:\GitHub\opencode---synapse-coder-reporter`) + +## 1. Config validation (Task 1.1) + +`.opencode/opencode.jsonc` contains `mcp.synapse-coder`: + +```jsonc +"synapse-coder": { + "type": "remote", + "url": "https://synapse-coder-mcp-staging.greenbay-703e5a45.australiaeast.azurecontainerapps.io/mcp", + "headers": { + "Authorization": "Bearer {env:SYNAPSE_CODER_STAGING_BEARER_TOKEN}", + }, +}, +``` + +No plaintext secret — `{env:VAR}` substitution only (NAC-003). + +**Fix applied during verification:** the two `local` MCP entries (`vault-local`, `azure-devops`) +used the wrong shape for this fork (`command` string + `args` + `env`). The schema +(`packages/core/src/v1/config/mcp.ts:6-23`) requires `command: string[]` (command+args combined), +`environment` (not `env`), and `enabled`. Corrected in the same file. + +## 2. `opencode mcp list` — server connects (AC-001) + +Command (from `packages/opencode`, token resolved from vault +`synapse-coder-mcp-staging-bearer-token` into `SYNAPSE_CODER_STAGING_BEARER_TOKEN`): + +``` +bun run --conditions=browser ./src/index.ts mcp list +``` + +Output (trimmed): + +``` +● ✓ synapse-coder connected +│ https://synapse-coder-mcp-staging.greenbay-703e5a45.australiaeast.azurecontainerapps.io/mcp +● ✓ keystone connected +● ✓ alterspective-rag connected +● ✓ vault-local connected +``` + +(Other entries in the list come from user-global config and are unrelated to this change.) + +## 3. Direct tool discovery — `coder_report_correction` present + +JSON-RPC `tools/list` against the staging facade with the vault bearer token: + +``` +HTTP 200 +tools (12): coder_generate, coder_fix, coder_models, coder_stats, coder_kpi, +coder_benchmark, coder_report_correction, coder_lesson_list, coder_lesson_promote, +coder_lesson_reject, coder_lesson_demote, coder_lesson_reindex +``` + +`coder_report_correction` is available for the reporter module (Phase 3). + +## 4. Plugin hook firing + +`tool.execute.after` firing is covered by the plugin unit/integration tests: +`packages/opencode/test/plugin/synapse-coder-reporter/synapse-coder-reporter.test.ts` +invokes the hook with simulated edit/write/apply_patch tool calls + LSP diagnostics +metadata and asserts detection, payload shape, opt-in gating, and fire-and-forget +behaviour. 23/23 pass in isolation; 34/34 across all three new test files +(stability: 6/6 clean consecutive combined runs). diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/evidence/phase4-e2e-verification.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/evidence/phase4-e2e-verification.md new file mode 100644 index 000000000000..82b01a62698e --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/evidence/phase4-e2e-verification.md @@ -0,0 +1,96 @@ +# Phase 4 Evidence: E2E Verification (Task 4.3) + +**Date:** 2026-07-19 +**Verifier:** AI agent (resume session) +**Branch:** `synapse-coder-reporter` (worktree `C:\GitHub\opencode---synapse-coder-reporter`) + +## Finding 1 — payload schema mismatch (caught by E2E, would have shipped broken) + +The plan (and the global agent-instructions doc) assumed the payload +`{ reporterModel, category, language, original, corrected, reason }`. +E2E against the real staging facade proved that wrong — `coder_report_correction` +rejected it with MCP -32602: + +``` +Input validation error: "tool" Required, "model" Required, too_small (empty string) +``` + +Actual schema (live `tools/list`, 2026-07-19): + +| Field | Requirement | +|---|---| +| `tool` | **required**, string minLength 1 — tool that produced the original output | +| `model` | **required**, string minLength 1 — model that produced the original output | +| `original` | **required**, string minLength 1 | +| `corrected` | **required**, string minLength 1 — **empty string rejected** | +| `reason`, `reporterModel`, `language`, `taskFamily`, `checkPattern` | optional | +| `category` | optional, kebab-case pattern | +| `severity` | optional enum info/minor/major/critical | +| | `additionalProperties: false` | + +**Consequence:** the one-sided design (`corrected: ""`) could never work. Redesigned: +a failing edit is held as *pending* and only reported when a follow-up edit to the +same file in the same session lands with clean diagnostics, pairing +`original` (failing edit) with `corrected` (fixing edit). Ungaired detections are +dropped after a 30-minute window. No placeholder values are sent, so the learning +corpus is not polluted. + +## Finding 2 — paired E2E against real staging: PASS (AC-005) + +Probe: real plugin instance, real network, staging facade, token from vault +`synapse-coder-mcp-staging-bearer-token`. + +Plugin structured log: + +```json +{"event":"synapse_correction_detected","reported":false,"reason":"awaiting_fix","category":"lsp-typecheck","language":"typescript"} +{"event":"synapse_correction_reported","reported":true,"category":"lsp-typecheck","language":"typescript"} +``` + +`coder_stats` on staging after the probe (`corrections` array, was empty before): + +```json +{"tool":"edit","model":"openrouter/kimi-k3","original":"const x: number = 'not a number'", + "corrected":"const x: number = 42", + "reason":"[{\"message\":\"E2E-VERIFY SYN-001 pairing 2026-07-18T15:46:35.361Z\",\"severity\":\"error\"}]", + "reporterModel":"openrouter/kimi-k3","category":"lsp-typecheck","language":"typescript", + "recordedAt":1784389596668} +``` + +The server accepted the correction (candidate only — lesson promotion remains +human-gated by design). + +## Test suite state + +- `packages/opencode/test/plugin/synapse-coder-reporter/synapse-coder-reporter.test.ts` — 28 tests: detection, tool filtering, language derivation, pairing rules (held/equal/different-file/window-expiry), opt-in gate, offline queue, model tracking, fire-and-forget, first-use toast. +- `test/plugin/synapse-coder-reporter/health-check.test.ts` — 5 tests (sidecar HTTP). +- `test/plugin/alterspective-rag-standards/alterspective-rag-standards.test.ts` — 6 tests. +- 34/34 pass (4/4 consecutive clean full-file runs); `bun typecheck` from `packages/opencode`: 0 errors. + +## Known flake (environmental) + +`bun test` intermittently reports "(unnamed) — a beforeEach/afterEach hook timed out" +(~10–45s) on Windows, single- or multi-file, with no deterministic repro. Deep +instrumentation on 2026-07-19 showed: + +- Every beforeEach/afterEach/afterAll hook completes in milliseconds; all 28 tests + finish in < 1.5s of wall time — the phantom is attributed by the runner **after + `afterAll` exits**, not by any real hook stall. +- `process._getActiveHandles()` / `_getActiveRequests()` at `afterAll`: **0/0** in + clean runs — no dangling timers, sockets, or file handles from the plugin or tests. +- Flake occurrences cluster right after file edits (Windows Defender rescan of fresh + files) and under tight-loop load; 25 consecutive clean runs once quiesced. +- Plugin report `AbortController` timeout hardened 30s → 10s as a precaution. + +Not a logic failure — all tests pass deterministically when the runner is not +stalled. The repo test script's `--only-failures` rerun absorbs residual flake. If +it recurs, re-apply the hook-timing + active-handle dump technique (recorded in +session history) to capture the handle list in a failing run. + +## Doc gap filed + +Global agent instructions (`C:\GitHub\AGENTS.md` "Synapse Coder" section, source: +`Alterspective-Intelligence\_meta\agent-config\C-GitHub-AGENTS.md`) document the +report payload without the required `tool`/`model` fields and the non-empty +`corrected` constraint. Cross-repo fix filed in `issues.md` — not edited from this +session per cross-repo change rules. diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/impact-analysis.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/impact-analysis.md new file mode 100644 index 000000000000..8782fba41ba7 --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/impact-analysis.md @@ -0,0 +1,57 @@ +# SYN-001: Impact Analysis + +## Files Affected + +### New files (added) + +| File | Purpose | Churn risk | +|------|---------|-----------| +| `opencode.json` (project root) or `~/.config/opencode/opencode.json` | MCP server config + plugin config + opt-in setting | Low — new file or additive to existing | +| `.opencode/plugin/synapse-coder-reporter.ts` | Plugin implementation (primary location — outside `packages/`, zero merge risk) | Low — new file outside packages/ | +| `packages/opencode/test/plugin/synapse-coder-reporter/*.test.ts` | Unit and integration tests | Low — new files | +| `docs/implementation/current/SYN-001-synapse-coder-reporter/*` | Plan documents (this directory) | Low — new files | + +### Existing files modified + +| File | Change | Churn risk | Justification | +|------|--------|-----------|---------------| +| None | — | — | Plugin-only approach: no core files touched | + +**This is the key fork-local compliance point.** The plugin-only approach means zero changes to high-churn core files (`edit.ts`, `write.ts`, `llm.ts`, `processor.ts`, `tools.ts`). All integration logic lives in a new plugin file. `git diff dev..synapse-coder-reporter -- packages/opencode/src/tool/ packages/opencode/src/session/ packages/opencode/src/lsp/` must return empty. + +## Risk Assessment + +| Risk | Category | Severity | Likelihood | Mitigation | +|------|----------|----------|------------|-----------| +| Synapse Coder staging facade down or rejects auth | Technical | Medium | Low | Health check on plugin load; graceful degradation; offline queue | +| User sends sensitive client code to Synapse without realizing | Privacy/Security | High | Medium | Opt-in gate (default off); first-use TUI toast; structured logging; disable at any time | +| Plugin hook overhead slows tool execution | Performance | Medium | Low | Async fire-and-forget; no `await` in hook path; benchmark < 5ms | +| v2 plugin system lands and deprecates v1 hooks | Technical/Fork-local | Medium | Low (no timeline for v2) | v1 hooks are live and stable; v2 is not wired into session loop yet; plugin can be migrated when v2 lands | +| One-sided corrections rejected by Synapse | Signal quality | Low | Medium | Verify accepted parameters in Phase 1; document Synapse's response; iterate | +| Plugin loader path changes in upstream merge | Fork-local | Low | Low | Plugin loaded from `plugin_origins` config; loader API is stable (`packages/opencode/src/plugin/loader.ts`) | +| MCP client config schema changes | Fork-local | Low | Low | `ConfigMCPV1.Info` schema is stable; `type: "remote"` + `url` + `headers` is a core feature | + +## Dependency Map + +| Dependency | Type | Status | +|------------|------|--------| +| Synapse Coder MCP staging facade | External service | Available (per `C:\GitHub\AGENTS.md`) | +| Vault secret `synapse-coder-mcp-staging-bearer-token` | Secret | Available via `vault-local` MCP or `az keyvault secret show` | +| opencode v1 plugin system (`Hooks` interface) | Internal API | Stable (`packages/plugin/src/index.ts:222-335`) | +| opencode MCP client (`MCP.Service`) | Internal API | Stable (`packages/opencode/src/mcp/index.ts`) | +| opencode `tool.execute.after` hook | Internal API | Stable (`packages/plugin/src/index.ts:274-281`) | +| opencode `event` hook | Internal API | Stable (`packages/plugin/src/index.ts:224`) | +| opencode `chat.message` hook | Internal API | Stable (`packages/plugin/src/index.ts:234-243`) | + +## Validation Strategy + +| Validation Type | Method | Gate | +|-----------------|--------|------| +| Static checks | `bun typecheck` from `packages/opencode` | 0 errors | +| Linting | `bun run lint` (if configured) | 0 errors/warnings | +| Unit tests | `bun test` from `packages/opencode` for plugin tests | All pass | +| Integration tests | Mock MCP + mock LSP; verify report payload | All pass | +| E2E | Real opencode session with a deliberate type error; verify Synapse staging receives report | Report logged on Synapse side | +| Fork-local compliance | `git diff dev..synapse-coder-reporter -- packages/opencode/src/tool/ packages/opencode/src/session/ packages/opencode/src/lsp/` | Empty | +| Security | `grep -r "Bearer" opencode.json` returns only env var ref | No plaintext secrets | +| Performance | Benchmark hook overhead with/without plugin | < 5ms per tool call | diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/issues.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/issues.md new file mode 100644 index 000000000000..9152954a7b53 --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/issues.md @@ -0,0 +1,9 @@ +# SYN-001: Issues Log + +| ID | Severity | Description | Status | Resolution | +|----|----------|-------------|--------|------------| +| ISS-001 | HIGH | `coder_report_correction` live schema requires `tool`, `model`, and non-empty `original`/`corrected` (`additionalProperties: false`) — the planned payload was rejected by the server. Caught only by E2E. | RESOLVED 2026-07-19 | Plugin redesigned: hold failing edits as pending; report only when a follow-up same-file edit lands with clean diagnostics, pairing `original`→`corrected`. Evidence: `evidence/phase4-e2e-verification.md`. | +| ISS-002 | MEDIUM | Global agent instructions (`C:\GitHub\AGENTS.md` "Synapse Coder" section; source `Alterspective-Intelligence\_meta\agent-config\C-GitHub-AGENTS.md`) document the correction payload without required `tool`/`model` and the non-empty `corrected` constraint. | FILED 2026-07-19 | Cross-repo doc fix needed (separate controlled work item per cross-repo rules). Update the payload description to the live schema. | +| ISS-003 | MEDIUM | Plan's opt-in design specified config schema key `synapse_coder.enabled` — extending the core config schema violates fork-local constraints. | RESOLVED 2026-07-19 | Deviation: env var `SYNAPSE_CODER_REPORTER_ENABLED` gates reporting (default off); first-use TUI toast via `client.tui.showToast()` with once-per-project marker `.opencode/synapse-coder-prompted`. AC-006/AC-010 satisfied without core changes. | +| ISS-004 | LOW | Local MCP entries in `.opencode/opencode.jsonc` used wrong shape (`command` string + `args` + `env`) — config validation failed at startup. | RESOLVED 2026-07-19 | Fixed to schema (`packages/core/src/v1/config/mcp.ts`): `command: string[]`, `environment`, `enabled: true`. `opencode mcp list` then showed `synapse-coder connected`. | +| ISS-005 | LOW | Health-check test resolved the sidecar at a nonexistent path (`test/plugin/scripts/`), so all 5 sidecar tests failed with "exited early code 1". | RESOLVED 2026-07-19 | Corrected relative path to repo-root `.opencode/scripts/health-check.ts`; 5/5 pass. | diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/module-register.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/module-register.md new file mode 100644 index 000000000000..fdb19000ad58 --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/module-register.md @@ -0,0 +1,41 @@ +# SYN-001: Module Register + +## Module: synapse-coder-reporter + +| Property | Value | +|----------|-------| +| Module ID | `synapse-coder-reporter` | +| Type | opencode v1 plugin | +| Location | `packages/opencode/src/plugin/plugins/synapse-coder-reporter.ts` (or `.opencode/plugin/synapse-coder-reporter.ts`) | +| Dependencies | Synapse Coder MCP staging facade (external), opencode v1 plugin API (`packages/plugin/src/index.ts`) | +| Config | `opencode.json` → `mcp.synapse-coder` + `plugin_origins` + `synapse_coder` (opt-in settings) | +| Hooks | `tool.execute.after`, `event`, `chat.message` | +| Tests | `packages/opencode/test/plugin/synapse-coder-reporter/` | + +## Dependency Mapping + +``` +synapse-coder-reporter (plugin) +├── opencode v1 Hooks API (packages/plugin/src/index.ts:222-335) +│ ├── tool.execute.after (line 274-281) +│ ├── event (line 224) +│ └── chat.message (line 234-243) +├── opencode MCP client (packages/opencode/src/mcp/index.ts) +│ └── synapse-coder MCP server (remote, staging facade) +│ └── coder_report_correction tool +├── opencode config (packages/core/src/v1/config/config.ts) +│ └── mcp + plugin_origins + synapse_coder keys +└── opencode tool metadata (read-only consumption) + ├── edit.ts metadata.diagnostics (line 203-208) + ├── write.ts metadata.diagnostics (line 92-100) + └── apply_patch.ts metadata.diagnostics (line 295-302) +``` + +## End-to-End Acceptance + +The module is accepted when: +1. A correction event (LSP diagnostics after edit) is detected by the plugin +2. The plugin builds a valid `coder_report_correction` payload +3. User opt-in is respected (no report if disabled) +4. The report reaches Synapse Coder staging and is visible in its logs +5. opencode continues normally throughout (no crashes, no blocking) diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/INDEX.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/INDEX.md new file mode 100644 index 000000000000..b53d0a8754dc --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/INDEX.md @@ -0,0 +1,12 @@ +# Module Index: synapse-coder-reporter + +| Document | Purpose | +|----------|---------| +| [requirements.md](requirements.md) | Module-specific requirements | +| [technical-design.md](technical-design.md) | Module design and hook points | +| [checklist.md](checklist.md) | Module task checklist | +| [status.md](status.md) | Module status | +| [ai-memory.md](ai-memory.md) | Module decisions and gotchas | +| [ai-handover.md](ai-handover.md) | Module continuity | + +Parent feature: [SYN-001](../../README.md) diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/README.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/README.md new file mode 100644 index 000000000000..8775bf66225c --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/README.md @@ -0,0 +1,9 @@ +# Module: synapse-coder-reporter + +**Parent:** [SYN-001-synapse-coder-reporter](../../README.md) +**Type:** opencode v1 plugin +**Status:** Planning + +A v1 opencode plugin that detects code corrections during opencode sessions (LSP diagnostics after edits, permission rejections with feedback) and reports them to Synapse Coder's `coder_report_correction` learning loop. + +See the parent [requirements](../../requirements.md), [technical design](../../technical-design.md), [acceptance criteria](../../acceptance-criteria.md), and [checklist](../../checklist.md) for full details. diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/ai-handover.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/ai-handover.md new file mode 100644 index 000000000000..42405bbdc755 --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/ai-handover.md @@ -0,0 +1,10 @@ +# Module AI Handover: synapse-coder-reporter + +See parent [ai-handover.md](../../ai-handover.md) for full session continuity. + +## Module-specific context + +- Plugin file location: `packages/opencode/src/plugin/plugins/synapse-coder-reporter.ts` (or `.opencode/plugin/synapse-coder-reporter.ts`) +- Plugin loaded via `plugin_origins` in `opencode.json` +- MCP config in `opencode.json` → `mcp.synapse-coder` +- Tests in `packages/opencode/test/plugin/synapse-coder-reporter/` diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/ai-memory.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/ai-memory.md new file mode 100644 index 000000000000..dc533204e1bc --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/ai-memory.md @@ -0,0 +1,10 @@ +# Module AI Memory: synapse-coder-reporter + +See parent [ai-memory.md](../../ai-memory.md) for full details. + +## Module-specific gotchas + +- The `write.ts` tool does NOT return `diff` in metadata (only in the permission ask) — use `input.args.content` as the original +- The `event` hook receives ALL events — filter early by type to avoid overhead +- The `chat.message` hook fires for user messages only, not assistant messages — the model is in the hook input, not in the assistant message +- MCP calls must be fire-and-forget; `await`-ing them in a hook blocks the tool execution loop diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/checklist.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/checklist.md new file mode 100644 index 000000000000..8593c2e9a61a --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/checklist.md @@ -0,0 +1,16 @@ +# Module Checklist: synapse-coder-reporter + +See parent [checklist.md](../../checklist.md) for full task breakdown. + +| Task | Status | +|------|--------| +| Create plugin file with `chat.message` hook | PENDING | +| Implement `tool.execute.after` hook for LSP diagnostics | PENDING | +| Implement `event` hook for permission rejections | PENDING | +| Implement language derivation utility | PENDING | +| Implement reporter module (MCP call) | PENDING | +| Implement user opt-in gate | PENDING | +| Implement offline queue | PENDING | +| Implement async fire-and-forget | PENDING | +| Unit tests | PENDING | +| Integration tests | PENDING | diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/requirements.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/requirements.md new file mode 100644 index 000000000000..4eda35f27a6a --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/requirements.md @@ -0,0 +1,15 @@ +# Module Requirements: synapse-coder-reporter + +See parent [requirements.md](../../requirements.md) for full details. + +## Module-specific requirements + +| ID | Requirement | +|----|-------------| +| M-001 | Plugin must implement the v1 `Hooks` interface (`packages/plugin/src/index.ts:222-335`) | +| M-002 | Plugin must register `tool.execute.after`, `event`, and `chat.message` hooks | +| M-003 | Plugin must not `await` MCP calls in hook paths (fire-and-forget) | +| M-004 | Plugin must store the per-session model ID from `chat.message` hook input | +| M-005 | Plugin must derive `language` from file extension | +| M-006 | Plugin must check `synapse_coder.enabled` config before reporting | +| M-007 | Plugin must queue failed reports in `.opencode/synapse-coder-queue.json` (max 100, FIFO) | diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/status.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/status.md new file mode 100644 index 000000000000..05b665cf54af --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/status.md @@ -0,0 +1,6 @@ +# Module Status: synapse-coder-reporter + +**Status:** Planning — awaiting review +**Last updated:** 2026-07-18 + +No implementation started. See parent [status.md](../../status.md). diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/technical-design.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/technical-design.md new file mode 100644 index 000000000000..8c138bf45499 --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/modules/synapse-coder-reporter/technical-design.md @@ -0,0 +1,76 @@ +# Module Technical Design: synapse-coder-reporter + +See parent [technical-design.md](../../technical-design.md) for full architecture. + +## Plugin structure + +```typescript +// synapse-coder-reporter.ts +import type { Hooks, PluginInput } from "@opencode-ai/plugin" + +export default function plugin(input: PluginInput): Promise { + // Per-session model map: sessionID → "providerID/modelID" + const modelMap = new Map() + // Offline queue + const queue = [] + + return { + async chat.message(event) { + // Track model ID per session + if (event.model) { + modelMap.set(event.sessionID, `${event.model.providerID}/${event.model.modelID}`) + } + }, + + async "tool.execute.after"(event, output) { + // Detect LSP diagnostics after edit/write/apply_patch + if (["edit", "write", "apply_patch"].includes(event.tool)) { + const diagnostics = output.metadata?.diagnostics + if (diagnostics && diagnostics.length > 0) { + await reportCorrection({ + original: extractOriginal(event.tool, event.args), + corrected: "", + category: "lsp-typecheck", + language: deriveLanguage(event.args.filePath), + reason: formatDiagnostics(diagnostics), + reporterModel: modelMap.get(event.sessionID) ?? "unknown", + }, input, queue) + } + } + }, + + async event(event) { + // Detect permission rejections + if (event.type === "Permission.Event.Replied" && event.status === "rejected") { + await reportCorrection({ + original: "", + corrected: "", + category: "user-rejection", + language: deriveLanguage(event.filePath), + reason: event.feedback ?? "user rejected", + reporterModel: modelMap.get(event.sessionID) ?? "unknown", + }, input, queue) + } + }, + } +} +``` + +## Hook evidence (file:line) + +| Hook | File:Line | Usage | +|------|-----------|-------| +| `chat.message` | `packages/plugin/src/index.ts:234-243` | Model ID tracking | +| `tool.execute.after` | `packages/plugin/src/index.ts:274-281` | LSP diagnostics detection | +| `event` | `packages/plugin/src/index.ts:224` | Permission rejection detection | + +## Report payload builder + +| Field | Source | Evidence | +|-------|--------|----------| +| `original` | `input.args.newString` (edit), `input.args.content` (write), `input.args.patchText` (apply_patch) | `packages/plugin/src/index.ts:275` — `input.args` | +| `corrected` | `""` (one-sided) | N/A — next-turn fix not available in hook | +| `category` | `"lsp-typecheck"` or `"user-rejection"` | N/A | +| `language` | File extension → language map | `input.args.filePath` | +| `reason` | Formatted diagnostics or user feedback | `output.metadata.diagnostics` or `event.feedback` | +| `reporterModel` | Per-session model map | `packages/plugin/src/index.ts:237-240` — `model: { providerID, modelID }` | diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/requirements.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/requirements.md new file mode 100644 index 000000000000..650e56c7b9fa --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/requirements.md @@ -0,0 +1,52 @@ +# SYN-001: Requirements + +## Goal + +Feed opencode's code-correction events into Synapse Coder's `coder_report_correction` learning loop, so corrections observed during opencode sessions grow the shared lesson corpus and improve future AI coding assistance across Alterspective. + +## Explicit Requirements + +| ID | Requirement | Source | +|----|-------------|--------| +| R-001 | The integration must not require core code changes (fork-local constraint) | `AGENTS.md` fork-local note | +| R-002 | User consent is required before any code is sent to Synapse (privacy) | `AIMETH-010`, `WEBSTA-001-SECRETS-MANAGEMENT` | +| R-003 | The integration must degrade gracefully when Synapse is unreachable (resilience) | `WEBSTA-001-ERROR-HANDLING-STANDARDS` | +| R-004 | The integration must report LSP diagnostics after edits (Signal 1). Permission rejection detection (Signal 2) is deferred to Phase 2 — the `permission.v2.replied` event doesn't carry feedback text and `tool.execute.after` doesn't fire on errors | Investigation findings + plan review | +| R-005 | The integration must use the Synapse Coder MCP staging facade (not prod) until validated | `AGENTS.md` Synapse Coder section | + +## Assumptions (with evidence) + +| # | Assumption | Evidence | +|---|------------|----------| +| A-001 | Synapse Coder staging facade is accessible at `https://synapse-coder-mcp-staging.greenbay-703e5a45.australiaeast.azurecontainerapps.io/mcp` | `C:\GitHub\AGENTS.md` Synapse Coder section | +| A-002 | Bearer token is in vault secret `synapse-coder-mcp-staging-bearer-token` | `C:\GitHub\AGENTS.md` Synapse Coder section | +| A-003 | opencode v1 plugin hooks are stable and won't break before v2 lands | Investigation: v2 is "in progress" (`packages/plugin/src/v2/effect/`) but not wired into the live session loop; v1 `Hooks` interface is live | +| A-004 | The `coder_report_correction` MCP tool accepts parameters: `reporterModel`, `category`, `language`, `original`, `corrected`, `reason` | `C:\GitHub\AGENTS.md` Synapse Coder section | +| A-005 | opencode's `opencode.json` `mcp` key supports remote HTTP servers with headers | Investigation: `packages/core/src/v1/config/mcp.ts:1`, `ConfigMCPV1.Info` has `type`, `url`, `headers` | +| A-006 | The `tool.execute.after` plugin hook receives `output.metadata` including `diagnostics` | Investigation: `packages/plugin/src/index.ts:274-281`, `packages/opencode/src/tool/edit.ts:203-208` | +| A-007 | The `chat.message` plugin hook carries `model: { providerID, modelID }` for `reporterModel` — but the field is OPTIONAL (`packages/plugin/src/index.ts:238` — `model?`). Fallback: query `client.session.get(sessionID)` or default to `"unknown"` | Investigation + plan review | + +## VISION.md + +**Status:** Absent. The opencode repository does not have a `VISION.md` file (verified: `Test-Path C:\GitHub\opencode\VISION.md` returned `False`). This is not required for a fork-local integration plugin — the opencode project is an upstream fork with its own product direction, and this feature is an Alterspective-specific add-on that does not alter the product's vision. The fork-local `AGENTS.md` note governs scope. + +## Routed Standards + +| Standard | Rule IDs | Application | +|----------|----------|-------------| +| `WEBSTA-001-SECRETS-MANAGEMENT` | `SEC-001` | No committed plaintext credentials; use vault refs for the bearer token | +| `WEBSTA-001-TESTING-STANDARDS` | `TST-VAL-*` | Validation claims must cite exact checks performed | +| `WEBSTA-001-ERROR-HANDLING-STANDARDS` | `ERR-*` | Graceful degradation when MCP unreachable | +| `MCP-STANDARDS` | `MCP-*` | opencode.json mcp config guidance (not `.mcp.json` — opencode uses its own config) | +| `AIMETH-005` | — | Git worktree discipline (worktree created) | +| `AIMETH-010` | — | Evidence-based verification | + +## Non-Functional Requirements + +| NFR | Target | +|-----|--------| +| Performance | Plugin hooks must not block the tool execution loop; reports fire async (fire-and-forget) | +| Privacy | No code leaves the machine without explicit user opt-in; per-session confirmation for first report | +| Reliability | Offline queue with retry; no crash if Synapse is unreachable | +| Fork-local | Zero changes to high-churn core files (`edit.ts`, `write.ts`, `llm.ts`, `processor.ts`) | +| Observability | Structured logging of what's sent; user-visible indicator when a report fires | diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/status.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/status.md new file mode 100644 index 000000000000..07fa654b2698 --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/status.md @@ -0,0 +1,52 @@ +# SYN-001: Status + +**Last updated:** 2026-07-19 +**Phase:** Implementation complete — verified (unit + typecheck + MCP wiring + paired E2E against staging). Awaiting commit decision. +**Branch:** `synapse-coder-reporter` +**Worktree:** `C:\GitHub\opencode---synapse-coder-reporter` + +## Current State + +All Phase 1 tasks implemented and verified. The E2E run against the real staging +service caught a payload schema mismatch (ISS-001) that unit tests could not — +fixed via a pairing redesign (report only `original`→`corrected` pairs; one-sided +detections are held 30 min then dropped). See `evidence/phase4-e2e-verification.md`. + +## Task Status + +| Task | Status | Notes | +|------|--------|-------| +| 1.1 — MCP server config | DONE | `synapse-coder` connected via `mcp list`; local-entry shape fixed (ISS-004). Evidence: `evidence/phase1-mcp-verification.md` | +| 1.2 — MCP tool discovery verification | DONE | 12 tools incl. `coder_report_correction`; evidence file written | +| 2.1 — Plugin scaffold + model tracking | DONE | `.opencode/plugin/synapse-coder-reporter.ts` | +| 2.2 — LSP diagnostics detection hook | DONE | Detects non-empty `metadata.diagnostics` on edit/write/apply_patch | +| 2.3 — Permission rejection detection | DEFERRED | Phase 2 — infeasible without core changes (plan review) | +| 2.4 — Language derivation utility | DONE | Extension map; covered by unit tests | +| 3.1 — Reporter module (MCP call) DONE (redesigned) | DONE | Direct JSON-RPC `tools/call`; payload matches live schema (ISS-001) | +| 3.2 — User opt-in gate | DONE (deviation) | Env var gate (default off) + first-use TUI toast, once-per-project marker (ISS-003) | +| 3.3 — Error handling + offline queue | DONE | `.opencode/synapse-coder-queue.json`, retry on load + 5-min timer, flush on dispose | +| 3.4 — Async fire-and-forget | DONE | No `await` on report path; covered by timing test | +| 4.1 — Unit tests | DONE | 28 tests in `test/plugin/synapse-coder-reporter/synapse-coder-reporter.test.ts` | +| 4.2 — Integration tests | DONE | Simulated LSP diagnostics → paired fix → mocked MCP call asserted end-to-end | +| 4.3 — E2E verification | DONE | Real staging accepted paired correction; `coder_stats` evidence (ISS-001 found & fixed) | + +## Validation Evidence + +- `bun typecheck` from `packages/opencode`: 0 errors (2026-07-19) +- `bun test` (3 new files): 34/34 pass, 4/4 consecutive clean runs (2026-07-19) +- `opencode mcp list`: `synapse-coder connected` (2026-07-19) +- Staging `coder_stats` contains the probe correction (2026-07-19) +- Fork-local: changes confined to `.opencode/`, `docs/`, `packages/opencode/test/` — no `src/` changes +- Known environmental test flake documented in `evidence/phase4-e2e-verification.md` + +## Companion Work (same worktree, outside SYN-001 scope) + +| Item | Status | Notes | +|------|--------|-------| +| `alterspective-rag-standards` plugin + 6 tests | DONE | System-prompt standards injection (Team A) | +| Health/version sidecar `.opencode/scripts/health-check.ts` + 5 tests | DONE | `/health` + `/version` (Team B; path bug fixed ISS-005) | +| Brand compliance verification | DONE | All compliant, no fixes needed (Team C report) | + +## Blockers + +None. Next decision: commit strategy for the worktree (SYN-001 work + companion items). diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/technical-design.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/technical-design.md new file mode 100644 index 000000000000..b4c7c642ed21 --- /dev/null +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/technical-design.md @@ -0,0 +1,240 @@ +# SYN-001: Technical Design + +## Architecture + +The integration has two components: + +1. **MCP Server Config** — Synapse Coder staging facade configured as a remote MCP server in `opencode.json`, exposing the `coder_report_correction` tool to opencode's LLM and plugin layer. +2. **Plugin (`synapse-coder-reporter`)** — A v1 opencode plugin that hooks `tool.execute.after` and `event` to detect corrections and call `coder_report_correction` via the MCP client. + +``` +┌─────────────────────────────────────────────────────────┐ +│ opencode session │ +│ │ +│ LLM ──► Edit/Write tool ──► LSP diagnostics │ +│ │ │ │ +│ ▼ ▼ │ +│ tool.execute.after metadata.diagnostics │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ synapse-coder-reporter plugin │ │ +│ │ │ │ +│ │ 1. Detect correction (diagnostics≠∅) │ │ +│ │ 2. Build report payload │ │ +│ │ 3. Check user opt-in │ │ +│ │ 4. Call coder_report_correction (MCP) │ │ +│ └────────────────┬────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────┐ │ +│ │ MCP client │ │ +│ │ (synapse- │ │ +│ │ coder) │ │ +│ └───────┬───────┘ │ +└──────────────────────┼──────────────────────────────────┘ + │ + ▼ (HTTPS) + ┌───────────────────────┐ + │ Synapse Coder │ + │ staging facade │ + │ (Azure Container Apps)│ + └───────────────────────┘ +``` + +## Component 1: MCP Server Config + +### Config location + +`opencode.json` at the project root (or `~/.config/opencode/opencode.json` for global). + +**Note:** opencode does NOT use `.mcp.json` (the Claude Code / Cursor convention). MCP servers are configured under a top-level `mcp` key in `opencode.json`. Evidence: `packages/core/src/v1/config/config.ts:113-115`, `packages/opencode/src/cli/cmd/mcp.ts:394-410`. + +### Config shape + +```jsonc +{ + "mcp": { + "synapse-coder": { + "type": "remote", + "url": "https://synapse-coder-mcp-staging.greenbay-703e5a45.australiaeast.azurecontainerapps.io/mcp", + "headers": { + "Authorization": "Bearer {env:SYNAPSE_CODER_STAGING_BEARER_TOKEN}" + } + } + } +} +``` + +The bearer token resolves from vault secret `synapse-coder-mcp-staging-bearer-token` (per `C:\GitHub\AGENTS.md`). The `{env:VAR}` syntax is opencode's config substitution format — verified at `packages/opencode/src/config/variable.ts:36` (regex `/\{env:([^}]+)\}/g`) and applied to all config text at `packages/opencode/src/config/config.ts:219-225`. **Note:** opencode does NOT use `${VAR}` (shell) or `{VAR}` — only `{env:VAR}`. + +Evidence for remote MCP + headers: `packages/core/src/v1/config/mcp.ts:1` (`ConfigMCPV1.Info` has `type: "local" | "remote"`, `url`, `headers`), `packages/opencode/src/mcp/index.ts:236-338` (`connectRemote` uses `StreamableHTTPClientTransport`). + +## Component 2: Plugin (`synapse-coder-reporter`) + +### Plugin shape + +A v1 plugin is a function `(input: PluginInput, options?) => Promise`. Evidence: `packages/plugin/src/index.ts:74`. + +The plugin is loaded from `cfg.plugin_origins` in `opencode.json`: + +```jsonc +{ + "plugin_origins": ["./.opencode/plugin/synapse-coder-reporter.ts"] +} +``` + +**Primary location: `.opencode/plugin/synapse-coder-reporter.ts`** — completely outside `packages/`, zero merge risk with upstream. Auto-discovered via `packages/opencode/src/config/plugin.ts:18-30` (scans `{plugin,plugins}/*.{ts,js}`) and `packages/opencode/src/config/config.ts:462-465`. Evidence: `packages/opencode/src/plugin/loader.ts` (`PluginLoader.loadExternal`). + +### Hooks to register + +| Hook | Line | Purpose | +|------|------|---------| +| `tool.execute.after` | `packages/plugin/src/index.ts:274-281` | Detect LSP diagnostics after `edit`/`write`/`apply_patch` tool calls | +| `chat.message` | `packages/plugin/src/index.ts:234-243` | Track current model ID per session (for `reporterModel`) | + +**Note:** The `event` hook (`packages/plugin/src/index.ts:224`) is NOT used in Phase 1 — the permission rejection signal (Signal 2) is deferred to Phase 2 because the `permission.v2.replied` event schema doesn't carry feedback text and `tool.execute.after` doesn't fire on errors. + +### Correction detection logic + +#### Signal 1: LSP diagnostics after edit (primary) + +**When:** `tool.execute.after` fires with `input.tool === "edit" | "write" | "apply_patch"`. + +**Check:** `output.metadata?.diagnostics` is non-empty (or the `output.output` string contains `"LSP errors detected in this file"`). + +**Payload:** +- `original`: `input.args.newString` (edit), `input.args.content` (write), or `input.args.patchText` (apply_patch) — the LLM's proposed code +- `corrected`: `""` (one-sided — the next-turn fix is not available in this hook; Synapse may accept one-sided reports with `reason` containing the diagnostics) +- `category`: `"lsp-typecheck"` +- `language`: derived from file extension (`input.args.filePath` → extension → language map) +- `reason`: `"LSP diagnostics: "` +- `reporterModel`: from the per-session model map (populated by `chat.message` hook) + +**Evidence:** `packages/opencode/src/tool/edit.ts:197-201` shows diagnostics collected and `:203-208` shows them returned in `metadata`. The `input.args` in `tool.execute.after` (`packages/plugin/src/index.ts:275`) carries the original LLM args including `newString`/`content`/`patchText`. + +#### Signal 2: Permission rejection (DEFERRED to Phase 2) + +**Status:** Deferred. The `permission.v2.replied` event schema (`packages/schema/src/permission.ts:44-51`) only carries `sessionID`, `requestID`, `reply` — no feedback field. The `tool.execute.after` hook does not fire when the tool throws (`packages/opencode/src/session/tools.ts:111` — `CorrectedError` is thrown before the trigger at line 121 is reached). Without a plugin-hookable path to the feedback text, this signal is not feasible in Phase 1 without core code changes. + +**Phase 2 approach (investigate):** Check whether `Session.Event.Error` or `session.tool.failed` events carry the `CorrectedError.feedback` text. If so, the `event` hook can capture it. If not, Signal 2 is limited to rejection-only (no feedback) or requires a core change to expose the feedback via a new event field. + +**Phase 1 scope:** Signal 1 only (LSP diagnostics). This is the primary correction signal and is fully feasible. + +### Reporting logic + +The plugin calls the `coder_report_correction` MCP tool via the opencode client (`input.client`) or via a direct MCP client connection. The opencode client exposes MCP tools through the HTTP API. + +**Flow:** +1. Detect correction (Signal 1 only in Phase 1) +2. Build payload (original, corrected, category, language, reason, reporterModel) +3. Check user opt-in setting (stored in opencode config under a `synapse_coder` key) +4. If opt-in: call `coder_report_correction` MCP tool (async, fire-and-forget — no `await` in the hook path) +5. If MCP call fails: queue locally (`.opencode/synapse-coder-queue.json`), retry on next correction or on a 5-minute timer +6. Log structured event: `{ correction detected, reported: true/false, category, language, model }` + +### Model ID fallback + +The `chat.message` hook's `model` field is optional (`packages/plugin/src/index.ts:238` — `model?: { providerID: string; modelID: string }`). When undefined (user didn't specify a model in their message), the plugin falls back to: +1. Query `input.client.session.get(sessionID)` for the session's current model +2. If that fails, use `"unknown"` as the `reporterModel` + +### User opt-in gate + +```jsonc +{ + "synapse_coder": { + "enabled": false, // opt-in, default off + "report_lsp_diagnostics": true, + "report_user_rejections": true, + "show_indicator": true + } +} +``` + +First time a correction is detected and `enabled` is not set, the plugin emits a TUI toast: "Synapse Coder learning loop detected a correction. Enable reporting? (y/n)". This uses the `tui.toast.show` event (`packages/schema/src/tui-event.ts:40-41`) via `client.tui.showToast()`. Dismissal = remain disabled (safe default). In headless/CLI mode (no TUI), the prompt is skipped and reporting stays disabled. + +### Error handling and offline queue + +- MCP call failures are caught and logged; they never propagate to the tool execution loop +- Failed reports queue in `.opencode/synapse-coder-queue.json` (max 100 entries, FIFO) +- Queue retries on plugin load and on a 5-minute timer +- If queue exceeds 100, oldest entries are dropped with a warning log + +## Options Analysis + +### Option A: Plugin-only (RECOMMENDED) + +**What:** MCP config + v1 plugin. No core code changes. + +**Pros:** +- Zero churn in high-churn core files (fork-local compliant) +- Reversible (disable plugin or remove MCP config) +- Works with v1 and v2 session paths (plugin hooks are stable) +- Ships fast (1-2 sessions) + +**Cons:** +- Can't capture pre-format LLM literal output (discarded in `edit.ts:112-114` before formatting) +- Can't hook `experimental_repairToolCall` (not plugin-hookable) +- One-sided corrections for LSP diagnostics (no next-turn fix pairing) + +### Option B: Core code changes + plugin + +**What:** Same as A, PLUS small code changes in `edit.ts`/`write.ts`/`apply_patch.ts` to snapshot `params.newString` before `format.file()` runs, and a new plugin hook in `llm.ts` for `experimental_repairToolCall`. + +**Pros:** +- Captures the cleanest before/after pairs (format-on-write, malformed tool calls) +- Two-sided corrections (higher signal quality) + +**Cons:** +- Touches high-churn core files — conflicts with upstream merges +- Violates the fork-local note: "keep every local change small and in low-churn files" +- Longer review cycle, higher risk + +**Decision:** Option A. The fork-local constraint is non-negotiable. Option B's signal gain is marginal vs the merge-conflict risk. Phase 2 can revisit if the v2 `FileMutation.Service` (`packages/core/src/file-mutation.ts:201-207`) lands and provides a stable hook point. + +### Option C: Custom tool only (LLM self-reports) + +**What:** Drop a `tools/synapse-coder.ts` in `.opencode/` that exposes `coder_report_correction` as an LLM-callable tool. Rely on prompt engineering to make the LLM self-report. + +**Pros:** Zero infrastructure beyond the tool file. + +**Cons:** LLMs rarely self-report corrections; signal quality is poor; doesn't capture silent corrections (format rewrites, LSP diagnostics). + +**Decision:** Rejected. Passive detection (Option A) captures signals the LLM wouldn't self-report. + +## Devil's Advocate + +- **"Why not just use Synapse Coder directly via its MCP for code generation?"** — opencode is the user's primary tool; they won't switch to Synapse Coder for every edit. Feeding corrections passively captures learnings without changing the user's workflow. The two tools serve different purposes: opencode is the daily driver; Synapse Coder is the learning loop beneficiary. +- **"Why not wait for the v2 plugin system?"** — v2 is "in progress" (`packages/plugin/src/v2/effect/`) with no timeline and not wired into the live session loop. Waiting delays value indefinitely. v1 hooks are stable and documented. +- **"Isn't this just telemetry?"** — No, it's a learning loop: corrections feed back into Synapse Coder's lesson corpus, which improves future code generation for all Alterspective agents. It's dogfooding our own AI. The user opts in and sees what's sent. + +## Pre-Mortem (failure modes) + +| # | Failure Mode | Impact | Mitigation | +|---|--------------|--------|-----------| +| FM-001 | Synapse Coder MCP staging facade is down or rejects the bearer token | Integration appears broken on first use | Health check on plugin load; graceful degradation; clear error log | +| FM-002 | Plugin hooks don't fire as expected for MCP tools | No corrections detected | Verify hook firing in Phase 1 Task 1.2 before building detection logic; fall back to built-in tools only | +| FM-003 | `coder_report_correction` rejects one-sided corrections | Reports are silently dropped | Verify accepted parameters in Phase 1; design for two-sided where possible; document Synapse's response | +| FM-004 | User accidentally sends sensitive client code to Synapse | Privacy incident | Opt-in gate (default off); per-session first-use confirmation; structured logging of what's sent; user can disable at any time | +| FM-005 | Plugin hook overhead slows down tool execution | UX degradation | All reporting is async (fire-and-forget); no `await` on MCP calls in the hook path | + +## Language Map + +File extension → `language` parameter for `coder_report_correction`: + +| Extension | Language | +|-----------|----------| +| `.ts` / `.tsx` | `typescript` | +| `.js` / `.jsx` / `.mjs` / `.cjs` | `javascript` | +| `.py` | `python` | +| `.go` | `go` | +| `.rs` | `rust` | +| `.java` | `java` | +| `.cs` | `csharp` | +| `.rb` | `ruby` | +| `.php` | `php` | +| `.swift` | `swift` | +| `.kt` / `.kts` | `kotlin` | +| `.dart` | `dart` | +| (other) | `text` | diff --git a/packages/opencode/test/plugin/synapse-coder-reporter/synapse-coder-reporter.test.ts b/packages/opencode/test/plugin/synapse-coder-reporter/synapse-coder-reporter.test.ts new file mode 100644 index 000000000000..e050ac42ded7 --- /dev/null +++ b/packages/opencode/test/plugin/synapse-coder-reporter/synapse-coder-reporter.test.ts @@ -0,0 +1,487 @@ +import { describe, expect, test, mock, beforeEach, afterEach, beforeAll, afterAll } from "bun:test" +import os from "node:os" +import path from "node:path" +import fs from "node:fs" +import plugin from "../../../../../.opencode/plugin/synapse-coder-reporter" + +// --- Save originals for restoration --- + +const originalFetch = globalThis.fetch +const originalLog = console.log +const originalError = console.error +const originalSetInterval = globalThis.setInterval +const originalClearInterval = globalThis.clearInterval + +// --- Mocks --- + +let fetchDelay = 0 +let fetchFailNext = false + +const fetchMock = mock(async (_url: string, _init: any) => { + if (fetchFailNext) { + fetchFailNext = false + throw new Error("network down") + } + if (fetchDelay > 0) await new Promise((r) => setTimeout(r, fetchDelay)) + return new Response( + JSON.stringify({ jsonrpc: "2.0", result: { content: [{ type: "text", text: "ok" }] } }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) +}) + +const logMock = mock((..._args: any[]) => {}) +const errorMock = mock((..._args: any[]) => {}) + +// --- Real temp directory for queue file I/O (Bun.file/Bun.write cannot be mocked) --- + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "synapse-reporter-test-")) +fs.mkdirSync(path.join(tempDir, ".opencode"), { recursive: true }) + +// --- Mock PluginInput --- + +const toastMock = mock(async (_opts: any) => ({})) + +const mockInput = { + client: { + session: { + get: mock(async () => ({ data: { modelID: "gpt-5", providerID: "openai" } })), + }, + tui: { + showToast: toastMock, + }, + }, + project: { id: "test-project" }, + directory: tempDir, + worktree: tempDir, + experimental_workspace: { register: () => {} }, + serverUrl: new URL("http://localhost:3000"), + $: {} as any, +} as any + +// --- Helpers --- + +function lastLogEntry(): any { + const calls = logMock.mock.calls + expect(calls.length).toBeGreaterThan(0) + return JSON.parse(calls[calls.length - 1][0]) +} + +async function flushMicrotasks(ms = 15): Promise { + await new Promise((r) => setTimeout(r, ms)) +} + +function lastFetchBody(): any { + const calls = fetchMock.mock.calls + expect(calls.length).toBeGreaterThan(0) + const lastCall = calls[calls.length - 1] as [string, any] + return JSON.parse(lastCall[1].body) +} + +function lastFetchPayload(): any { + return lastFetchBody().params.arguments +} + +// --- Tests --- + +describe("synapse-coder-reporter", () => { + let hooks: any + + beforeAll(() => { + globalThis.fetch = fetchMock as any + console.log = logMock as any + console.error = errorMock as any + globalThis.setInterval = (() => 0) as any + globalThis.clearInterval = (() => {}) as any + }) + + afterAll(() => { + globalThis.fetch = originalFetch + console.log = originalLog + console.error = originalError + globalThis.setInterval = originalSetInterval + globalThis.clearInterval = originalClearInterval + try { + fs.rmSync(tempDir, { recursive: true, force: true }) + } catch {} + }) + + beforeEach(async () => { + delete process.env.SYNAPSE_CODER_REPORTER_ENABLED + delete process.env.SYNAPSE_CODER_STAGING_BEARER_TOKEN + delete process.env.SYNAPSE_CODER_PENDING_WINDOW_MS + fetchDelay = 0 + fetchFailNext = false + fetchMock.mockClear() + logMock.mockClear() + errorMock.mockClear() + toastMock.mockClear() + toastMock.mockImplementation(async () => ({})) + // Clean queue file and prompt marker so each test starts fresh + try { + fs.unlinkSync(path.join(tempDir, ".opencode", "synapse-coder-queue.json")) + } catch {} + try { + fs.unlinkSync(path.join(tempDir, ".opencode", "synapse-coder-prompted")) + } catch {} + hooks = await plugin(mockInput) + }) + + afterEach(async () => { + if (hooks?.dispose) { + try { + await hooks.dispose() + } catch {} + } + await flushMicrotasks(5) + }) + + describe("language derivation", () => { + test("derives typescript from .ts extension", async () => { + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts", newString: "x" } }, + { title: "t", output: "o", metadata: { diagnostics: [{ message: "Type error" }] } }, + ) + const entry = lastLogEntry() + expect(entry.language).toBe("typescript") + }) + + test("derives python from .py extension", async () => { + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.py", newString: "x" } }, + { title: "t", output: "o", metadata: { diagnostics: [{ message: "Type error" }] } }, + ) + expect(lastLogEntry().language).toBe("python") + }) + + test("derives go from .go extension", async () => { + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.go", newString: "x" } }, + { title: "t", output: "o", metadata: { diagnostics: [{ message: "Type error" }] } }, + ) + expect(lastLogEntry().language).toBe("go") + }) + + test("derives text from unknown .xyz extension", async () => { + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.xyz", newString: "x" } }, + { title: "t", output: "o", metadata: { diagnostics: [{ message: "Type error" }] } }, + ) + expect(lastLogEntry().language).toBe("text") + }) + }) + + describe("diagnostics detection", () => { + test("detects correction from non-empty diagnostics array", async () => { + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts", newString: "x" } }, + { title: "t", output: "o", metadata: { diagnostics: [{ message: "Type error" }] } }, + ) + expect(logMock.mock.calls.length).toBeGreaterThan(0) + }) + + test("does not detect correction from empty diagnostics array", async () => { + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts", newString: "x" } }, + { title: "t", output: "o", metadata: { diagnostics: [] } }, + ) + expect(logMock.mock.calls.length).toBe(0) + expect(fetchMock.mock.calls.length).toBe(0) + }) + + test("detects correction from object-keyed diagnostics", async () => { + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts", newString: "x" } }, + { title: "t", output: "o", metadata: { diagnostics: { "/test/foo.ts": [{ message: "error" }] } } }, + ) + expect(logMock.mock.calls.length).toBeGreaterThan(0) + }) + + test("does not detect correction when metadata is undefined", async () => { + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts", newString: "x" } }, + { title: "t", output: "o", metadata: undefined }, + ) + expect(logMock.mock.calls.length).toBe(0) + expect(fetchMock.mock.calls.length).toBe(0) + }) + + test("does not detect correction from empty metadata object", async () => { + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts", newString: "x" } }, + { title: "t", output: "o", metadata: {} }, + ) + expect(logMock.mock.calls.length).toBe(0) + expect(fetchMock.mock.calls.length).toBe(0) + }) + }) + + describe("tool filtering", () => { + const diagMeta = { diagnostics: [{ message: "err" }] } + + test("processes edit tool with diagnostics", async () => { + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts", newString: "x" } }, + { title: "t", output: "o", metadata: diagMeta }, + ) + expect(logMock.mock.calls.length).toBeGreaterThan(0) + }) + + test("processes write tool with diagnostics", async () => { + await hooks["tool.execute.after"]( + { tool: "write", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts", content: "x" } }, + { title: "t", output: "o", metadata: diagMeta }, + ) + expect(logMock.mock.calls.length).toBeGreaterThan(0) + }) + + test("processes apply_patch tool with diagnostics", async () => { + await hooks["tool.execute.after"]( + { tool: "apply_patch", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts", patchText: "@@ @@" } }, + { title: "t", output: "o", metadata: diagMeta }, + ) + expect(logMock.mock.calls.length).toBeGreaterThan(0) + }) + + test("ignores bash tool even with diagnostics", async () => { + await hooks["tool.execute.after"]( + { tool: "bash", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts" } }, + { title: "t", output: "o", metadata: diagMeta }, + ) + expect(logMock.mock.calls.length).toBe(0) + expect(fetchMock.mock.calls.length).toBe(0) + }) + }) + + describe("correction pairing", () => { + beforeEach(() => { + process.env.SYNAPSE_CODER_REPORTER_ENABLED = "true" + process.env.SYNAPSE_CODER_STAGING_BEARER_TOKEN = "test-token" + }) + + async function failThenFix(tool: string, originalArgs: any, fixedArgs: any, filePath = "/test/foo.ts") { + await hooks["tool.execute.after"]( + { tool, sessionID: "s1", callID: "c1", args: { filePath, ...originalArgs } }, + { title: "t", output: "o", metadata: { diagnostics: [{ message: "err" }] } }, + ) + await hooks["tool.execute.after"]( + { tool, sessionID: "s1", callID: "c2", args: { filePath, ...fixedArgs } }, + { title: "t", output: "o", metadata: { diagnostics: [] } }, + ) + await flushMicrotasks() + } + + test("reports original from failing edit and corrected from fixing edit", async () => { + await failThenFix("edit", { newString: "const x = 1" }, { newString: "const x = 2" }) + expect(lastFetchPayload().original).toBe("const x = 1") + expect(lastFetchPayload().corrected).toBe("const x = 2") + expect(lastFetchPayload().tool).toBe("edit") + }) + + test("pairs write tool content", async () => { + await failThenFix("write", { content: "broken" }, { content: "fixed" }) + expect(lastFetchPayload().original).toBe("broken") + expect(lastFetchPayload().corrected).toBe("fixed") + }) + + test("pairs apply_patch patchText", async () => { + await failThenFix("apply_patch", { patchText: "@@ bad @@" }, { patchText: "@@ good @@" }) + expect(lastFetchPayload().original).toBe("@@ bad @@") + expect(lastFetchPayload().corrected).toBe("@@ good @@") + }) + + test("does not report on diagnostics alone (one-sided held)", async () => { + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts", newString: "x" } }, + { title: "t", output: "o", metadata: { diagnostics: [{ message: "err" }] } }, + ) + await flushMicrotasks() + expect(fetchMock.mock.calls.length).toBe(0) + expect(lastLogEntry().reason).toBe("awaiting_fix") + }) + + test("does not report when corrected equals original", async () => { + await failThenFix("edit", { newString: "same" }, { newString: "same" }) + expect(fetchMock.mock.calls.length).toBe(0) + }) + + test("does not pair a follow-up edit to a different file", async () => { + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts", newString: "x" } }, + { title: "t", output: "o", metadata: { diagnostics: [{ message: "err" }] } }, + ) + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c2", args: { filePath: "/test/bar.ts", newString: "y" } }, + { title: "t", output: "o", metadata: { diagnostics: [] } }, + ) + await flushMicrotasks() + expect(fetchMock.mock.calls.length).toBe(0) + }) + + test("drops pending after the pairing window expires", async () => { + process.env.SYNAPSE_CODER_PENDING_WINDOW_MS = "1" + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts", newString: "x" } }, + { title: "t", output: "o", metadata: { diagnostics: [{ message: "err" }] } }, + ) + await flushMicrotasks(10) + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c2", args: { filePath: "/test/foo.ts", newString: "y" } }, + { title: "t", output: "o", metadata: { diagnostics: [] } }, + ) + await flushMicrotasks() + expect(fetchMock.mock.calls.length).toBe(0) + expect(lastLogEntry().event).toBe("synapse_correction_dropped") + }) + + test("queues the report when synapse is unreachable", async () => { + fetchFailNext = true + await failThenFix("edit", { newString: "bad" }, { newString: "good" }) + await flushMicrotasks(30) + const queue = JSON.parse(fs.readFileSync(path.join(tempDir, ".opencode", "synapse-coder-queue.json"), "utf8")) + expect(queue.length).toBe(1) + expect(queue[0].original).toBe("bad") + expect(queue[0].corrected).toBe("good") + }) + }) + + describe("opt-in gate", () => { + test("logs reported=false and does not call fetch when opt-in disabled", async () => { + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts", newString: "x" } }, + { title: "t", output: "o", metadata: { diagnostics: [{ message: "err" }] } }, + ) + await flushMicrotasks() + + expect(logMock.mock.calls.length).toBeGreaterThan(0) + const entry = lastLogEntry() + expect(entry.reported).toBe(false) + expect(entry.reason).toBe("opt_in_disabled") + expect(fetchMock.mock.calls.length).toBe(0) + }) + + test("calls fetch when opt-in enabled and token set", async () => { + process.env.SYNAPSE_CODER_REPORTER_ENABLED = "true" + process.env.SYNAPSE_CODER_STAGING_BEARER_TOKEN = "test-token" + + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts", newString: "bad" } }, + { title: "t", output: "o", metadata: { diagnostics: [{ message: "err" }] } }, + ) + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c2", args: { filePath: "/test/foo.ts", newString: "good" } }, + { title: "t", output: "o", metadata: { diagnostics: [] } }, + ) + await flushMicrotasks() + + expect(fetchMock.mock.calls.length).toBeGreaterThan(0) + const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1] as [string, any] + expect(lastCall[1].headers.Authorization).toBe("Bearer test-token") + }) + }) + + describe("model tracking", () => { + test("attaches session model to the report payload", async () => { + process.env.SYNAPSE_CODER_REPORTER_ENABLED = "true" + process.env.SYNAPSE_CODER_STAGING_BEARER_TOKEN = "test-token" + + await hooks["chat.message"]( + { sessionID: "s1", model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" } }, + { message: {}, parts: [] }, + ) + + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts", newString: "bad" } }, + { title: "t", output: "o", metadata: { diagnostics: [{ message: "err" }] } }, + ) + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c2", args: { filePath: "/test/foo.ts", newString: "good" } }, + { title: "t", output: "o", metadata: { diagnostics: [] } }, + ) + await flushMicrotasks() + + expect(lastFetchPayload().model).toBe("anthropic/claude-sonnet-4-5") + expect(lastFetchPayload().reporterModel).toBe("anthropic/claude-sonnet-4-5") + }) + }) + + describe("first-use opt-in prompt", () => { + const diagMeta = { diagnostics: [{ message: "err" }] } + + test("shows toast once on first disabled correction and writes marker", async () => { + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts", newString: "x" } }, + { title: "t", output: "o", metadata: diagMeta }, + ) + await flushMicrotasks() + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c2", args: { filePath: "/test/foo.ts", newString: "y" } }, + { title: "t", output: "o", metadata: diagMeta }, + ) + await flushMicrotasks() + + expect(toastMock.mock.calls.length).toBe(1) + const body = (toastMock.mock.calls[0] as [any])[0].body + expect(body.variant).toBe("info") + expect(body.message).toContain("SYNAPSE_CODER_REPORTER_ENABLED") + expect(fs.existsSync(path.join(tempDir, ".opencode", "synapse-coder-prompted"))).toBe(true) + }) + + test("does not show toast when opt-in enabled", async () => { + process.env.SYNAPSE_CODER_REPORTER_ENABLED = "true" + process.env.SYNAPSE_CODER_STAGING_BEARER_TOKEN = "test-token" + + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts", newString: "x" } }, + { title: "t", output: "o", metadata: diagMeta }, + ) + await flushMicrotasks() + + expect(toastMock.mock.calls.length).toBe(0) + }) + + test("skips prompt and writes no marker when headless (showToast fails)", async () => { + toastMock.mockImplementation(async () => { + throw new Error("no tui") + }) + + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts", newString: "x" } }, + { title: "t", output: "o", metadata: diagMeta }, + ) + await flushMicrotasks() + + expect(fs.existsSync(path.join(tempDir, ".opencode", "synapse-coder-prompted"))).toBe(false) + expect(lastLogEntry().reason).toBe("opt_in_disabled") + }) + }) + + describe("fire-and-forget", () => { + test("tool.execute.after hook returns before fetch resolves", async () => { + process.env.SYNAPSE_CODER_REPORTER_ENABLED = "true" + process.env.SYNAPSE_CODER_STAGING_BEARER_TOKEN = "test-token" + + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c1", args: { filePath: "/test/foo.ts", newString: "bad" } }, + { title: "t", output: "o", metadata: { diagnostics: [{ message: "err" }] } }, + ) + + fetchDelay = 500 + const start = Date.now() + await hooks["tool.execute.after"]( + { tool: "edit", sessionID: "s1", callID: "c2", args: { filePath: "/test/foo.ts", newString: "good" } }, + { title: "t", output: "o", metadata: { diagnostics: [] } }, + ) + const elapsed = Date.now() - start + + // Hook should return well before the 500ms fetch delay + expect(elapsed).toBeLessThan(200) + + // Wait for microtasks to flush so fetch is initiated + await flushMicrotasks(20) + expect(fetchMock.mock.calls.length).toBeGreaterThan(0) + + // Wait for the slow fetch to complete so no dangling promises remain + await flushMicrotasks(550) + }) + }) +}) From 674bded6f7024621b1aaa0bd3094e6ec86371f59 Mon Sep 17 00:00:00 2001 From: Igor Jericevich Date: Sun, 19 Jul 2026 21:51:46 +1000 Subject: [PATCH 2/4] feat(plugin): add alterspective-rag-standards system-prompt plugin Statically injects Alterspective standards awareness (rule-ID citation, RAG-first routing via rag_search/rag_ask, standards index pointers) into the chat system prompt via the experimental.chat.system.transform hook. Opt-out via ALTERSPECTIVE_STANDARDS_INJECTION_DISABLED=true or ALTERSPECTIVE_STANDARDS_INJECTION_ENABLED=false. 6 tests. --- .../plugin/alterspective-rag-standards.ts | 49 +++++++++++++++ .../alterspective-rag-standards.test.ts | 63 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 .opencode/plugin/alterspective-rag-standards.ts create mode 100644 packages/opencode/test/plugin/alterspective-rag-standards/alterspective-rag-standards.test.ts diff --git a/.opencode/plugin/alterspective-rag-standards.ts b/.opencode/plugin/alterspective-rag-standards.ts new file mode 100644 index 000000000000..b81f4624297d --- /dev/null +++ b/.opencode/plugin/alterspective-rag-standards.ts @@ -0,0 +1,49 @@ +import type { Plugin } from "@opencode-ai/plugin" + +const STANDARDS_SYSTEM_PROMPT = ` +## Alterspective Standards Awareness + +When asked about best practices, patterns, or standards, prefer Alterspective company standards over generic industry practices. + +Alterspective standards are available via the \`alterspective-rag\` MCP server. Use the \`rag_search\` or \`rag_ask\` tools to query for relevant standards when: +- The user asks "how to", "what's the best practice", "what's the standard for" +- You're making architectural or pattern recommendations +- You're reviewing code for compliance + +Key standards areas and their rule ID prefixes: +- Coding standards: WEBSTA-001-CODING-* +- Testing standards: WEBSTA-001-TESTING / TST-VAL-* +- Documentation standards: WEBSTA-001-DOCUMENTATION / DOC-* +- Git standards: WEBSTA-001-GIT-STANDARDS / GIT-* +- Error handling: WEBSTA-001-ERROR-HANDLING / ERR-* +- Security: WEBSTA-001-SECURITY / SEC-* +- UX/UI standards: UX-* (Active standards are mandatory) +- MCP standards: WEBSTA-001-MCP-STANDARDS / MCP-* + +When an Active standard applies: +1. Cite the rule ID (e.g., CFG-WT-01, SEC-001) +2. Flag violations explicitly +3. Never silently deviate — if deviation is necessary, state the rule ID and reason +4. Treat Draft standards as strong guidance + +Standards routing indexes: +- Web/API/CLI/infrastructure: load \`Principles/Web/standards/index.md\` +- Sharedo platform: load \`Principles/Sharedo/standards/index.md\` +- Both apply: load both indexes +`.trim() + +function injectionEnabled(): boolean { + if (process.env.ALTERSPECTIVE_STANDARDS_INJECTION_DISABLED === "true") return false + return process.env.ALTERSPECTIVE_STANDARDS_INJECTION_ENABLED !== "false" +} + +const plugin: Plugin = async () => { + return { + "experimental.chat.system.transform": async (_input, output) => { + if (!injectionEnabled()) return + output.system.push(STANDARDS_SYSTEM_PROMPT) + }, + } +} + +export default plugin diff --git a/packages/opencode/test/plugin/alterspective-rag-standards/alterspective-rag-standards.test.ts b/packages/opencode/test/plugin/alterspective-rag-standards/alterspective-rag-standards.test.ts new file mode 100644 index 000000000000..54ec93106cd1 --- /dev/null +++ b/packages/opencode/test/plugin/alterspective-rag-standards/alterspective-rag-standards.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test, beforeAll, afterEach } from "bun:test" +import plugin from "../../../../../.opencode/plugin/alterspective-rag-standards" + +const mockInput = {} as any + +const originalEnabled = process.env.ALTERSPECTIVE_STANDARDS_INJECTION_ENABLED +const originalDisabled = process.env.ALTERSPECTIVE_STANDARDS_INJECTION_DISABLED + +describe("alterspective-rag-standards", () => { + let hooks: any + + beforeAll(async () => { + delete process.env.ALTERSPECTIVE_STANDARDS_INJECTION_ENABLED + delete process.env.ALTERSPECTIVE_STANDARDS_INJECTION_DISABLED + hooks = await plugin(mockInput) + }) + + afterEach(() => { + if (originalEnabled === undefined) delete process.env.ALTERSPECTIVE_STANDARDS_INJECTION_ENABLED + else process.env.ALTERSPECTIVE_STANDARDS_INJECTION_ENABLED = originalEnabled + if (originalDisabled === undefined) delete process.env.ALTERSPECTIVE_STANDARDS_INJECTION_DISABLED + else process.env.ALTERSPECTIVE_STANDARDS_INJECTION_DISABLED = originalDisabled + }) + + test("returns hooks with experimental.chat.system.transform", () => { + expect(typeof hooks["experimental.chat.system.transform"]).toBe("function") + }) + + test("hook appends a string to output.system", async () => { + const output = { system: [] as string[] } + await hooks["experimental.chat.system.transform"]({ model: {} as any }, output) + expect(output.system.length).toBe(1) + expect(typeof output.system[0]).toBe("string") + }) + + test("added string contains Alterspective Standards Awareness heading", async () => { + const output = { system: [] as string[] } + await hooks["experimental.chat.system.transform"]({ model: {} as any }, output) + expect(output.system[0]).toContain("Alterspective Standards Awareness") + }) + + test("added string mentions rag_search or rag_ask", async () => { + const output = { system: [] as string[] } + await hooks["experimental.chat.system.transform"]({ model: {} as any }, output) + expect(output.system[0].includes("rag_search") || output.system[0].includes("rag_ask")).toBe(true) + }) + + test("does NOT inject when ALTERSPECTIVE_STANDARDS_INJECTION_DISABLED=true", async () => { + process.env.ALTERSPECTIVE_STANDARDS_INJECTION_DISABLED = "true" + const localHooks: any = await plugin(mockInput) + const output = { system: [] as string[] } + await localHooks["experimental.chat.system.transform"]({ model: {} as any }, output) + expect(output.system.length).toBe(0) + }) + + test("does NOT inject when ALTERSPECTIVE_STANDARDS_INJECTION_ENABLED=false", async () => { + process.env.ALTERSPECTIVE_STANDARDS_INJECTION_ENABLED = "false" + const localHooks: any = await plugin(mockInput) + const output = { system: [] as string[] } + await localHooks["experimental.chat.system.transform"]({ model: {} as any }, output) + expect(output.system.length).toBe(0) + }) +}) From ed62b64eced53e79209f0f247de50a2254b99db2 Mon Sep 17 00:00:00 2001 From: Igor Jericevich Date: Sun, 19 Jul 2026 21:52:04 +1000 Subject: [PATCH 3/4] feat(scripts): add health/version sidecar for the opencode server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone HTTP sidecar exposing /health and /version (upstream probe optional via OPENCODE_SERVER_URL) — the fork-local fallback for operational visibility, since adding routes would touch high-churn upstream server files. 5 tests. --- .opencode/scripts/health-check.ts | 165 ++++++++++++++++++ .../health-check.test.ts | 110 ++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 .opencode/scripts/health-check.ts create mode 100644 packages/opencode/test/plugin/synapse-coder-reporter/health-check.test.ts diff --git a/.opencode/scripts/health-check.ts b/.opencode/scripts/health-check.ts new file mode 100644 index 000000000000..2583df099860 --- /dev/null +++ b/.opencode/scripts/health-check.ts @@ -0,0 +1,165 @@ +/** + * Fork-local health/version sidecar for the opencode server. + * + * The opencode HTTP server is assembled from high-churn upstream files + * (`packages/opencode/src/server/routes/instance/httpapi/server.ts` and + * `groups/global.ts` / `handlers/global.ts`) and exposes no plugin hook for + * adding raw HTTP routes (the plugin `Hooks` surface covers tools, events, + * chat, auth — not routes). Modifying those files would break the fork-local + * "keep changes small and in low-churn files" rule. This sidecar is the + * sanctioned fallback: a standalone HTTP server that runs alongside opencode + * and exposes `/health` and `/version` for operational visibility. + * + * Run alongside opencode: + * bun .opencode/scripts/health-check.ts + * + * Configuration (env): + * HEALTH_CHECK_PORT listen port (default 4040) + * HEALTH_CHECK_HOST listen host (default 0.0.0.0) + * BUILD_SHA git short SHA injected at build time (default "unknown") + * BUILD_DATE build date injected at build time (default "unknown") + * OPENCODE_SERVER_URL base URL of the real opencode server to probe (optional) + * when set, /health includes `upstream: { ok, status, version }` + */ + +import { createServer, type IncomingMessage, type ServerResponse } from "node:http" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, resolve as pathResolve, join as pathJoin } from "node:path" + +const PORT = Number.parseInt(process.env.HEALTH_CHECK_PORT ?? "4040", 10) || 4040 +const HOST = process.env.HEALTH_CHECK_HOST ?? "0.0.0.0" +const BUILD_SHA = process.env.BUILD_SHA ?? "unknown" +const BUILD_DATE = process.env.BUILD_DATE ?? "unknown" +const OPENCODE_SERVER_URL = process.env.OPENCODE_SERVER_URL ?? "" + +const startedAt = Date.now() + +function readOpencodeVersion(): string { + // Walk up from .opencode/scripts/ to repo root, then packages/opencode/package.json. + const here = dirname(fileURLToPath(import.meta.url)) + const candidates = [ + pathResolve(here, "..", "..", "packages", "opencode", "package.json"), + pathResolve(here, "..", "..", "package.json"), + ] + for (const candidate of candidates) { + try { + const pkg = JSON.parse(readFileSync(candidate, "utf8")) as { version?: string } + if (typeof pkg.version === "string" && pkg.version.length > 0) return pkg.version + } catch { + // try next candidate + } + } + return "unknown" +} + +const VERSION = readOpencodeVersion() + +type UpstreamStatus = + | { ok: true; status: number; version: string | null } + | { ok: false; status: number | null; error: string } + +async function probeUpstream(signal: AbortSignal): Promise { + const url = `${OPENCODE_SERVER_URL.replace(/\/$/, "")}/global/health` + try { + const res = await fetch(url, { signal, method: "GET" }) + if (!res.ok) return { ok: false, status: res.status, error: `upstream returned ${res.status}` } + const body = (await res.json()) as { healthy?: boolean; version?: string } + if (body.healthy !== true) return { ok: false, status: res.status, error: "upstream not healthy" } + return { ok: true, status: res.status, version: typeof body.version === "string" ? body.version : null } + } catch (err) { + return { ok: false, status: null, error: err instanceof Error ? err.message : String(err) } + } +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + const payload = JSON.stringify(body) + res.statusCode = status + res.setHeader("Content-Type", "application/json; charset=utf-8") + res.setHeader("Cache-Control", "no-store") + res.setHeader("X-Content-Type-Options", "nosniff") + res.end(payload) +} + +async function handleHealth(req: IncomingMessage, res: ServerResponse): Promise { + if (req.method !== "GET") { + sendJson(res, 405, { error: "Method Not Allowed" }) + return + } + const timestamp = new Date().toISOString() + const uptime = Math.round((Date.now() - startedAt) / 1000) + const base = { status: "ok" as const, timestamp, uptime } + if (!OPENCODE_SERVER_URL) { + sendJson(res, 200, base) + return + } + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 2000) + try { + const upstream = await probeUpstream(controller.signal) + sendJson(res, 200, { ...base, upstream }) + } finally { + clearTimeout(timeout) + } +} + +function handleVersion(req: IncomingMessage, res: ServerResponse): void { + if (req.method !== "GET") { + sendJson(res, 405, { error: "Method Not Allowed" }) + return + } + // Bun.version is defined on the Bun runtime; fall back to null elsewhere. + const bunVersion = typeof (globalThis as { Bun?: { version?: string } }).Bun?.version === "string" + ? (globalThis as { Bun: { version: string } }).Bun.version + : null + sendJson(res, 200, { + version: VERSION, + buildSha: BUILD_SHA, + buildDate: BUILD_DATE, + node: process.versions.node ? `v${process.versions.node}` : "unknown", + bun: bunVersion ?? "unknown", + }) +} + +function handleRoot(req: IncomingMessage, res: ServerResponse): void { + sendJson(res, 200, { + service: "opencode-health-sidecar", + endpoints: ["/health", "/version"], + }) +} + +const server = createServer((req, res) => { + const url = req.url ?? "/" + const path = url.split("?")[0] + if (path === "/health") { + void handleHealth(req, res) + return + } + if (path === "/version") { + handleVersion(req, res) + return + } + if (path === "/" || path === "") { + handleRoot(req, res) + return + } + sendJson(res, 404, { error: "Not Found", path }) +}) + +server.listen(PORT, HOST, () => { + const addr = server.address() + const binding = typeof addr === "object" && addr !== null ? `${addr.address}:${addr.port}` : `${HOST}:${PORT}` + // eslint-disable-next-line no-console + console.log(`opencode health sidecar listening on http://${binding} (version=${VERSION}, buildSha=${BUILD_SHA})`) +}) + +function shutdown(signal: string): void { + // eslint-disable-next-line no-console + console.log(`opencode health sidecar received ${signal}, shutting down`) + server.close(() => process.exit(0)) +} + +process.on("SIGTERM", () => shutdown("SIGTERM")) +process.on("SIGINT", () => shutdown("SIGINT")) + +export { handleHealth, handleVersion, readOpencodeVersion, probeUpstream } diff --git a/packages/opencode/test/plugin/synapse-coder-reporter/health-check.test.ts b/packages/opencode/test/plugin/synapse-coder-reporter/health-check.test.ts new file mode 100644 index 000000000000..4eda46629a6c --- /dev/null +++ b/packages/opencode/test/plugin/synapse-coder-reporter/health-check.test.ts @@ -0,0 +1,110 @@ +import { test, expect, describe, afterEach } from "bun:test" +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process" +import { resolve as pathResolve } from "node:path" + +const scriptPath = pathResolve(import.meta.dir, "..", "..", "..", "..", "..", ".opencode", "scripts", "health-check.ts") + +function startSidecar(env: Record): ChildProcessWithoutNullStreams { + return spawn(process.execPath, [scriptPath], { + env: { ...process.env, ...env }, + stdio: ["pipe", "pipe", "pipe"], + }) +} + +function waitForPort(proc: ChildProcessWithoutNullStreams, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("sidecar did not announce port in time")), timeoutMs) + proc.stdout.on("data", (chunk: Buffer) => { + if (chunk.toString().includes("listening on http://")) { + clearTimeout(timer) + resolve() + } + }) + proc.on("error", (err) => { + clearTimeout(timer) + reject(err) + }) + proc.on("exit", (code) => { + clearTimeout(timer) + reject(new Error(`sidecar exited early with code ${code}`)) + }) + }) +} + +async function fetchJson(url: string): Promise<{ status: number; body: unknown }> { + const res = await fetch(url) + const body = await res.json() + return { status: res.status, body } +} + +describe("opencode health sidecar", () => { + let proc: ChildProcessWithoutNullStreams | null = null + + afterEach(() => { + if (proc && !proc.killed) proc.kill("SIGTERM") + proc = null + }) + + test("GET /health returns ok with timestamp and uptime", async () => { + proc = startSidecar({ HEALTH_CHECK_PORT: "4141" }) + await waitForPort(proc) + const { status, body } = await fetchJson("http://127.0.0.1:4141/health") + expect(status).toBe(200) + expect(body).toEqual({ + status: "ok", + timestamp: expect.any(String), + uptime: expect.any(Number), + }) + const payload = body as { timestamp: string; uptime: number } + expect(() => new Date(payload.timestamp).toISOString()).not.toThrow() + expect(payload.uptime).toBeGreaterThanOrEqual(0) + }) + + test("GET /version returns version, buildSha, buildDate, node, bun", async () => { + proc = startSidecar({ + HEALTH_CHECK_PORT: "4142", + BUILD_SHA: "abc1234", + BUILD_DATE: "2026-07-18", + }) + await waitForPort(proc) + const { status, body } = await fetchJson("http://127.0.0.1:4142/version") + expect(status).toBe(200) + expect(body).toEqual({ + version: expect.any(String), + buildSha: "abc1234", + buildDate: "2026-07-18", + node: expect.any(String), + bun: expect.any(String), + }) + const payload = body as { version: string } + expect(payload.version).not.toBe("unknown") + }) + + test("GET / returns endpoint listing", async () => { + proc = startSidecar({ HEALTH_CHECK_PORT: "4143" }) + await waitForPort(proc) + const { status, body } = await fetchJson("http://127.0.0.1:4143/") + expect(status).toBe(200) + expect(body).toEqual({ + service: "opencode-health-sidecar", + endpoints: ["/health", "/version"], + }) + }) + + test("unknown path returns 404", async () => { + proc = startSidecar({ HEALTH_CHECK_PORT: "4144" }) + await waitForPort(proc) + const { status, body } = await fetchJson("http://127.0.0.1:4144/nope") + expect(status).toBe(404) + expect(body).toEqual({ error: "Not Found", path: "/nope" }) + }) + + test("POST /health returns 405", async () => { + proc = startSidecar({ HEALTH_CHECK_PORT: "4145" }) + await waitForPort(proc) + const res = await fetch("http://127.0.0.1:4145/health", { method: "POST" }) + expect(res.status).toBe(405) + const body = await res.json() + expect(body).toEqual({ error: "Method Not Allowed" }) + }) +}) From 3bf11e4d0b0f81ec8caec18b7e945aa99501d0ef Mon Sep 17 00:00:00 2001 From: Igor Jericevich Date: Sun, 19 Jul 2026 22:01:22 +1000 Subject: [PATCH 4/4] docs: mark ISS-002 resolved (global payload doc corrected + deployed) --- .../current/SYN-001-synapse-coder-reporter/issues.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/implementation/current/SYN-001-synapse-coder-reporter/issues.md b/docs/implementation/current/SYN-001-synapse-coder-reporter/issues.md index 9152954a7b53..1a4524f4a41b 100644 --- a/docs/implementation/current/SYN-001-synapse-coder-reporter/issues.md +++ b/docs/implementation/current/SYN-001-synapse-coder-reporter/issues.md @@ -3,7 +3,7 @@ | ID | Severity | Description | Status | Resolution | |----|----------|-------------|--------|------------| | ISS-001 | HIGH | `coder_report_correction` live schema requires `tool`, `model`, and non-empty `original`/`corrected` (`additionalProperties: false`) — the planned payload was rejected by the server. Caught only by E2E. | RESOLVED 2026-07-19 | Plugin redesigned: hold failing edits as pending; report only when a follow-up same-file edit lands with clean diagnostics, pairing `original`→`corrected`. Evidence: `evidence/phase4-e2e-verification.md`. | -| ISS-002 | MEDIUM | Global agent instructions (`C:\GitHub\AGENTS.md` "Synapse Coder" section; source `Alterspective-Intelligence\_meta\agent-config\C-GitHub-AGENTS.md`) document the correction payload without required `tool`/`model` and the non-empty `corrected` constraint. | FILED 2026-07-19 | Cross-repo doc fix needed (separate controlled work item per cross-repo rules). Update the payload description to the live schema. | +| ISS-002 | MEDIUM | Global agent instructions (`C:\GitHub\AGENTS.md` "Synapse Coder" section; source `Alterspective-Intelligence\_meta\agent-config\C-GitHub-AGENTS.md`) document the correction payload without required `tool`/`model` and the non-empty `corrected` constraint. | RESOLVED 2026-07-19 | Fixed in `Alterspective-Intelligence---synapse-coder-payload-doc` worktree (branch `docs/global-agents-synapse-payload`, commits e45e465 backfill + db70f3f correction), deployed to the live global file (`sync-global-agents.ps1 -Mode deploy`, verified IN SYNC). Same fix applied to the duplicate in user-global `~/.claude/CLAUDE.md`. OPS-001 checked — unaffected. | | ISS-003 | MEDIUM | Plan's opt-in design specified config schema key `synapse_coder.enabled` — extending the core config schema violates fork-local constraints. | RESOLVED 2026-07-19 | Deviation: env var `SYNAPSE_CODER_REPORTER_ENABLED` gates reporting (default off); first-use TUI toast via `client.tui.showToast()` with once-per-project marker `.opencode/synapse-coder-prompted`. AC-006/AC-010 satisfied without core changes. | | ISS-004 | LOW | Local MCP entries in `.opencode/opencode.jsonc` used wrong shape (`command` string + `args` + `env`) — config validation failed at startup. | RESOLVED 2026-07-19 | Fixed to schema (`packages/core/src/v1/config/mcp.ts`): `command: string[]`, `environment`, `enabled: true`. `opencode mcp list` then showed `synapse-coder connected`. | | ISS-005 | LOW | Health-check test resolved the sidecar at a nonexistent path (`test/plugin/scripts/`), so all 5 sidecar tests failed with "exited early code 1". | RESOLVED 2026-07-19 | Corrected relative path to repo-root `.opencode/scripts/health-check.ts`; 5/5 pass. |