From 8fb76944a7d0af3447a35d509d87a4993043f8dc Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 3 Aug 2026 19:42:47 -0400 Subject: [PATCH 01/22] feat(bug-report): orchestrator create/arm/open + bridge allowlist & lifecycle kinds (amicode#250) --- packages/extension/src/bug_report.ts | 286 ++++++++++++++++++++ packages/extension/src/chat_bridge.ts | 31 +++ packages/extension/test/bug_report.test.ts | 115 ++++++++ packages/extension/test/chat_bridge.test.ts | 57 ++++ 4 files changed, 489 insertions(+) create mode 100644 packages/extension/src/bug_report.ts create mode 100644 packages/extension/test/bug_report.test.ts diff --git a/packages/extension/src/bug_report.ts b/packages/extension/src/bug_report.ts new file mode 100644 index 0000000..669f722 --- /dev/null +++ b/packages/extension/src/bug_report.ts @@ -0,0 +1,286 @@ +import * as path from "node:path"; + +// ============================================================================ +// Bug-report orchestration (amicode#250, lifecycle spec: docs/adr/0004). +// +// The extension owns the bug session end-to-end — no reverse lookup, no +// app-reported ids. `reportBug()` creates the session (title "Bug report", +// metadata envelope `bug_report: {project, run_pointer?, origin_session_id}`), +// arms it with the report-a-bug slash command (the session command API idiom: +// create + command), and tells the app to open the dock. The lifecycle is +// machine-managed: `bug-filed` → archive (the soft hide) + close the dock; +// `bug-report-closed` before filing → abort the in-flight turn, then hard +// delete; a partial orchestration failure deletes the session it created (no +// orphans). One bug session at a time per window: a second invocation reveals +// (re-posts open-bug-report with the EXISTING id — the dock treats a same-id +// open as reveal) instead of creating. +// +// Bridge contract (mirrored by the fork slices #116/#117): +// DOWN {source:"amicode", kind:"open-bug-report", sessionID} +// DOWN {source:"amicode", kind:"close-bug-report", sessionID} +// UP {source:"amicode", kind:"bug-filed", sessionID, url} +// UP {source:"amicode", kind:"bug-report-closed", sessionID} +// +// Sanitization invariant: run context travels as run-id POINTERS only (never +// an absolute path, never a payload); the originating session id is a +// provenance pointer — it is only ever READ (the list call) and embedded, never +// mutated, navigated, or closed (AC6). +// ============================================================================ + +export const REPORT_BUG_COMMAND = "amicode.reportBug"; +export const BUG_REPORT_TITLE = "Bug report"; +/** The staged skill whose name is also its slash-command (opencode command + * API: skills register as commands under their frontmatter `name`). */ +export const REPORT_A_BUG_SKILL = "report-a-bug"; + +export const OPEN_BUG_REPORT_KIND = "open-bug-report"; +export const CLOSE_BUG_REPORT_KIND = "close-bug-report"; +export const BUG_FILED_KIND = "bug-filed"; +export const BUG_REPORT_CLOSED_KIND = "bug-report-closed"; + +/** The `amicode_bug_report=1` boot param is set iff the staged skill set + * includes report-a-bug (AC5) — the composer button never renders without the + * skill that answers it. Staged paths are `//SKILL.md` and the + * library/package layouts share the name-matches-folder rule, so the parent + * dir basename IS the skill name. */ +export function bugReportSkillStaged(skillPaths: readonly string[]): boolean { + return skillPaths.some((p) => path.basename(path.dirname(p)) === REPORT_A_BUG_SKILL); +} + +/** The running local opencode server + the #163 boot credential header value. */ +export interface BugReportServer { + url: string; + authorization: string; +} + +export interface BugReportDeps { + /** undefined while the server is down — the command then fails actionably. */ + server(): BugReportServer | undefined; + /** The VS Code workspace folder (the project the app's sessions live in); + * undefined in a no-folder window (→ the server's own cwd scope). */ + workspaceDir(): string | undefined; + /** The active (inspector-selected) run's POINTER — the registry runId, + * relative to the runs root, never an absolute path; undefined when none. */ + activeRunPointer(): string | undefined; + /** DOWN lane to the main app surface (the dock's host). */ + postDown(msg: unknown): void; + showError(message: string): void; + log?(line: string): void; + fetchImpl?: typeof fetch; +} + +/** The bridge sink the chat/deck panels wire into their BridgeIo — the two + * up-kinds, shape-validated by the bridge before they reach here. */ +export interface BugReportBridgeSink { + filed(sessionID: string, url: string): void; + closed(sessionID: string): void; +} + +export class BugReportManager { + /** The one live bug session (single-open invariant). Cleared BEFORE the + * terminal call on both lifecycle paths, so a late/duplicate message for + * the same id reads as unknown and is dropped. */ + private current?: string; + /** In-flight create+arm — concurrent invocations join it, never double-create. */ + private opening?: Promise; + + constructor(private readonly deps: BugReportDeps) {} + + /** Stable sink object for BridgeIo wiring. */ + readonly sink: BugReportBridgeSink = { + filed: (sessionID, url) => void this.onBugFiled(sessionID, url), + closed: (sessionID) => void this.onBugReportClosed(sessionID), + }; + + /** The `amicode.reportBug` command: reveal the open bug session, else + * create + arm + open a new one. Never two bug sessions. */ + async reportBug(): Promise { + if (this.current) { + this.deps.postDown({ source: "amicode", kind: OPEN_BUG_REPORT_KIND, sessionID: this.current }); + return; + } + if (this.opening) { + // A concurrent invocation joins the in-flight open, then reveals. + await this.opening; + if (this.current) { + this.deps.postDown({ source: "amicode", kind: OPEN_BUG_REPORT_KIND, sessionID: this.current }); + } + return; + } + this.opening = this.open(); + try { + await this.opening; + } finally { + this.opening = undefined; + } + } + + // -------- internal -------- + + private async open(): Promise { + const server = this.deps.server(); + if (!server) { + this.deps.showError( + "Amicode: opencode server isn't ready yet. Check the 'Amicode — opencode' output channel.", + ); + return; + } + // Context envelope (pointer-only): assembled BEFORE create so the new bug + // session can never be picked as its own origin. Each field degrades to + // absent rather than failing the report. + const envelope = await this.buildEnvelope(server); + let sessionID: string | undefined; + try { + sessionID = await this.createSession(server, envelope); + await this.armSession(server, sessionID); + } catch (e) { + // No orphans: a created-but-unarmed (or ambiguous) session is deleted. + if (sessionID) await this.deleteSession(server, sessionID); + this.deps.showError(`Amicode: couldn't start the bug report — ${(e as Error).message}`); + return; + } + this.current = sessionID; + this.deps.postDown({ source: "amicode", kind: OPEN_BUG_REPORT_KIND, sessionID }); + } + + private async buildEnvelope(server: BugReportServer): Promise> { + const envelope: Record = {}; + const dir = this.deps.workspaceDir(); + if (dir) envelope.project = path.basename(dir); + const runPointer = this.deps.activeRunPointer(); + if (runPointer && !path.isAbsolute(runPointer)) envelope.run_pointer = runPointer; + const origin = await this.findOriginSession(server); + if (origin) envelope.origin_session_id = origin; + return envelope; + } + + /** The originating session, best-effort: the most recently updated root + * session in the app's project scope that isn't itself a bug session (the + * list is updated-DESC and excludes archived). READ-ONLY provenance — this + * id is never a mutation target (AC6). undefined when unknowable. */ + private async findOriginSession(server: BugReportServer): Promise { + try { + const url = new URL("/session", server.url); + const dir = this.deps.workspaceDir(); + if (dir) url.searchParams.set("directory", dir); + const res = await this.fetch(url, server, { method: "GET" }); + if (!res.ok) return undefined; + const sessions = (await res.json()) as Array<{ + id?: unknown; + parentID?: unknown; + metadata?: unknown; + }>; + if (!Array.isArray(sessions)) return undefined; + const origin = sessions.find( + (s) => + typeof s?.id === "string" && + !s.parentID && + !(s.metadata && typeof s.metadata === "object" && "bug_report" in s.metadata), + ); + return origin?.id as string | undefined; + } catch { + return undefined; // best-effort: a failed list never blocks the report + } + } + + private async createSession(server: BugReportServer, envelope: Record): Promise { + const url = new URL("/session", server.url); + const dir = this.deps.workspaceDir(); + if (dir) url.searchParams.set("directory", dir); + const res = await this.fetch(url, server, { + method: "POST", + body: { title: BUG_REPORT_TITLE, metadata: { bug_report: envelope } }, + }); + const body = res.ok ? ((await res.json()) as { id?: unknown }) : undefined; + if (!body || typeof body.id !== "string" || body.id === "") { + throw new Error(`session create failed (HTTP ${res.status})`); + } + return body.id; + } + + /** Arm: the report-a-bug slash command as the session's first turn. */ + private async armSession(server: BugReportServer, sessionID: string): Promise { + const res = await this.fetch(new URL(`/session/${sessionID}/command`, server.url), server, { + method: "POST", + body: { command: REPORT_A_BUG_SKILL, arguments: "" }, + }); + if (!res.ok) throw new Error(`couldn't arm the report-a-bug skill (HTTP ${res.status})`); + } + + /** Filed → archive (the soft hide, restorable) and close the dock. Unknown + * ids — including a late bug-filed for an already-terminal session — drop. */ + private async onBugFiled(sessionID: string, url: string): Promise { + if (sessionID !== this.current) return; + this.current = undefined; // terminal latch: later messages for this id are unknown + this.deps.log?.(`[bug] filed (${url}) — archiving ${sessionID}`); + const server = this.deps.server(); + if (server) { + try { + const res = await this.fetch(new URL(`/session/${sessionID}`, server.url), server, { + method: "PATCH", + body: { time: { archived: Date.now() } }, + }); + if (!res.ok) this.deps.log?.(`[bug] archive failed (HTTP ${res.status}) — closing the dock anyway`); + } catch (e) { + this.deps.log?.(`[bug] archive failed (${(e as Error).message}) — closing the dock anyway`); + } + } + this.deps.postDown({ source: "amicode", kind: CLOSE_BUG_REPORT_KIND, sessionID }); + } + + /** Closed before filing → abort the in-flight turn, then hard delete. */ + private async onBugReportClosed(sessionID: string): Promise { + if (sessionID !== this.current) return; + this.current = undefined; + const server = this.deps.server(); + if (!server) return; + // Abort is best-effort (an idle session may 400 — nothing to abort); the + // hard delete is the guarantee. + try { + await this.fetch(new URL(`/session/${sessionID}/abort`, server.url), server, { method: "POST" }); + } catch (e) { + this.deps.log?.(`[bug] abort failed (${(e as Error).message}) — deleting anyway`); + } + await this.deleteSession(server, sessionID); + } + + private async deleteSession(server: BugReportServer, sessionID: string): Promise { + try { + const res = await this.fetch(new URL(`/session/${sessionID}`, server.url), server, { method: "DELETE" }); + if (!res.ok) this.deps.log?.(`[bug] delete ${sessionID} failed (HTTP ${res.status})`); + } catch (e) { + this.deps.log?.(`[bug] delete ${sessionID} failed (${(e as Error).message})`); + } + } + + private fetch(url: URL, server: BugReportServer, init: { method: string; body?: unknown }): Promise { + const fetchImpl = this.deps.fetchImpl ?? fetch; + return fetchImpl(url.toString(), { + method: init.method, + headers: { + Authorization: server.authorization, + ...(init.body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}), + }); + } +} + +// -------- module singleton (per-window; the extension host IS the window) -------- + +let active: BugReportManager | undefined; + +/** Register the window's manager (extension activation). Returns it for the + * command wiring. */ +export function registerBugReport(deps: BugReportDeps): BugReportManager { + active = new BugReportManager(deps); + return active; +} + +export function getBugReport(): BugReportManager | undefined { + return active; +} + +export function unregisterBugReport(): void { + active = undefined; +} diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts index 5ad4d7e..b2a6c60 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -22,18 +22,31 @@ export const BRIDGE_ALLOWED_COMMANDS: ReadonlySet = new Set([ "amicode.savePulse", "amicode.openRunDir", "amicode.openInspector", + // The composer's report-a-bug button (fork #116) posts this over the command + // lane; the registered command owns the bug session end-to-end (#250). + "amicode.reportBug", // ⌘⇧P inside the chat iframe lands in the APP's palette, not VS Code's — // the fork forwards it here so the editor's Command Palette (where every // Amicode: command lives) opens as users expect. "workbench.action.showCommands", ]); +/** The bug-session lifecycle sink (amicode#250) — the panels wire the + * BugReportManager's. Structural, so the bridge never imports the manager. */ +export interface BugReportSink { + filed(sessionID: string, url: string): void; + closed(sessionID: string): void; +} + /** Side channels the handler needs from its host panel. */ export interface BridgeIo { /** Clipboard reads only answer while the user can see the chat. */ visible(): boolean; /** Replies (clipboard text) go back to the host webview; `tab` echoes along. */ postToWebview(msg: unknown): void; + /** Bug-session lifecycle (bug-filed / bug-report-closed). Undefined until the + * manager registers at activation; the kinds are consumed regardless. */ + bugReport?: BugReportSink; } const isAmicode = (msg: unknown): msg is { source: "amicode"; kind: string; tab?: string } => @@ -123,6 +136,24 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean return false; } + // Bug-session lifecycle up-kinds (#250): the dock's sentinel watcher reports + // a filing, the close control reports a pre-file abandon. The manager owns + // the known-id check (unknown ids drop there); we only shape-validate. + // Consumed either way — these are our envelopes, never foreign noise. + if (msg.kind === "bug-filed") { + const sessionID = (msg as unknown as { sessionID?: unknown }).sessionID; + const url = (msg as unknown as { url?: unknown }).url; + if (typeof sessionID === "string" && sessionID !== "") { + io.bugReport?.filed(sessionID, typeof url === "string" ? url : ""); + } + return true; + } + if (msg.kind === "bug-report-closed") { + const sessionID = (msg as unknown as { sessionID?: unknown }).sessionID; + if (typeof sessionID === "string" && sessionID !== "") io.bugReport?.closed(sessionID); + return true; + } + // Dashboard "Default model" control mirrors its choice into the // amicode.defaultModel setting, so the config pin (headless / first turn) // tracks the UI. "provider/model-id" only, bounded — untrusted. diff --git a/packages/extension/test/bug_report.test.ts b/packages/extension/test/bug_report.test.ts new file mode 100644 index 0000000..9a6ab3f --- /dev/null +++ b/packages/extension/test/bug_report.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from "vitest"; +import { BugReportManager, type BugReportDeps } from "../src/bug_report"; + +// ============================================================================ +// amicode#250 — the extension owns the bug session end-to-end: create (title +// "Bug report" + the context-envelope metadata), arm (the report-a-bug slash +// command), open (open-bug-report down the bridge), and the machine-managed +// lifecycle (archive on filed, abort+delete on abandon, delete on partial +// failure). The server API is mocked at the fetch seam (cloud_key precedent). +// ============================================================================ + +const SERVER_URL = "http://127.0.0.1:43117/"; +const BOOT_AUTH = "Basic b3BlbmNvZGU6dGVzdC1ib290LXBhc3N3b3Jk"; + +type Call = { method: string; url: string; body?: unknown }; + +/** Fetch stub routing per (method, path-prefix); records every call. */ +function mockFetch(routes: Record): { + fetchImpl: typeof fetch; + calls: Call[]; +} { + const calls: Call[] = []; + const fetchImpl = (async (input: string | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? "GET"; + const body = typeof init?.body === "string" ? (JSON.parse(init.body) as unknown) : undefined; + calls.push({ method, url, body }); + const path = new URL(url).pathname; + const route = routes[`${method} ${path}`]; + const status = route?.status ?? 404; + return { + ok: status >= 200 && status < 300, + status, + json: async () => route?.body, + } as Response; + }) as unknown as typeof fetch; + return { fetchImpl, calls }; +} + +function deps(overrides: Partial, fetchImpl: typeof fetch) { + const posted: unknown[] = []; + const errors: string[] = []; + const d: BugReportDeps = { + server: () => ({ url: SERVER_URL, authorization: BOOT_AUTH }), + workspaceDir: () => "/home/researcher/emerald-q3", + activeRunPointer: () => undefined, + postDown: (m) => posted.push(m), + showError: (m) => errors.push(m), + fetchImpl, + ...overrides, + }; + return { d, posted, errors }; +} + +describe("amicode.reportBug — create, arm, open (AC1)", () => { + it("creates exactly one session with the title + metadata envelope, arms it, and sends one open-bug-report", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [{ id: "ses_origin", time: { created: 1, updated: 2 } }] }, + "POST /session": { status: 200, body: { id: "ses_bug1" } }, + "POST /session/ses_bug1/command": { status: 200, body: {} }, + }); + const { d, posted } = deps({ activeRunPointer: () => "default/20260803-104655-x-gate" }, fetchImpl); + + await new BugReportManager(d).reportBug(); + + const creates = calls.filter((c) => c.method === "POST" && new URL(c.url).pathname === "/session"); + expect(creates).toHaveLength(1); + expect(creates[0].body).toEqual({ + title: "Bug report", + metadata: { + bug_report: { + project: "emerald-q3", + run_pointer: "default/20260803-104655-x-gate", + origin_session_id: "ses_origin", + }, + }, + }); + const arm = calls.filter((c) => c.url.endsWith("/session/ses_bug1/command")); + expect(arm).toHaveLength(1); + expect(arm[0].body).toEqual({ command: "report-a-bug", arguments: "" }); + expect(posted).toEqual([{ source: "amicode", kind: "open-bug-report", sessionID: "ses_bug1" }]); + }); + + it("omits run_pointer when no run is active, and never sends an absolute path", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "POST /session": { status: 200, body: { id: "ses_bug2" } }, + "POST /session/ses_bug2/command": { status: 200, body: {} }, + }); + const { d } = deps({ activeRunPointer: () => undefined }, fetchImpl); + + await new BugReportManager(d).reportBug(); + + const create = calls.find((c) => c.method === "POST" && new URL(c.url).pathname === "/session"); + const meta = (create?.body as { metadata: { bug_report: Record } }).metadata.bug_report; + expect("run_pointer" in meta).toBe(false); + expect(String(meta.run_pointer ?? "")).not.toMatch(/^\//); + }); + + it("authenticates every server call with the per-boot credential (#163)", async () => { + const authed: Array = []; + const fetchImpl = (async (input: string | URL, init?: RequestInit) => { + authed.push((init?.headers as Record | undefined)?.Authorization); + const path = new URL(String(input)).pathname; + const body = path === "/session" && init?.method === "POST" ? { id: "ses_bug3" } : path === "/session" ? [] : {}; + return { ok: true, status: 200, json: async () => body } as Response; + }) as unknown as typeof fetch; + const { d } = deps({}, fetchImpl); + + await new BugReportManager(d).reportBug(); + + expect(authed.length).toBeGreaterThan(0); + expect(new Set(authed)).toEqual(new Set([BOOT_AUTH])); + }); +}); diff --git a/packages/extension/test/chat_bridge.test.ts b/packages/extension/test/chat_bridge.test.ts index 280a1bd..8f5d3b6 100644 --- a/packages/extension/test/chat_bridge.test.ts +++ b/packages/extension/test/chat_bridge.test.ts @@ -83,6 +83,14 @@ describe("amicode bridge — commands & settings", () => { expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "command", command: "workbench.action.terminal.kill" }, host)).toBe(false); }); + it("allowlists amicode.reportBug — the composer bug button's command lane (amicode#250)", async () => { + const host = io(); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "command", command: "amicode.reportBug" }, host)).toBe(true); + await flush(); + const ran = (vscode.commands as unknown as { executed: string[] }).executed ?? []; + expect(ran).toContain("amicode.reportBug"); + }); + it("set-default-model accepts provider/model-id shapes and mirrors them to config", () => { const host = io(); expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "set-default-model", model: "anthropic/claude-sonnet-5" }, host)).toBe(true); @@ -98,3 +106,52 @@ describe("amicode bridge — commands & settings", () => { expect(handleAmicodeBridgeMessage("a string", host)).toBe(false); }); }); + +describe("amicode bridge — bug-report lifecycle kinds (amicode#250)", () => { + /** BridgeIo with the bug-report sink wired (the panels pass the manager's). */ + function ioWithSink(visible = true) { + const host = io(visible); + const filed: Array<{ sessionID: string; url: string }> = []; + const closed: string[] = []; + host.bugReport = { + filed: (sessionID, url) => filed.push({ sessionID, url }), + closed: (sessionID) => closed.push(sessionID), + }; + return { host, filed, closed }; + } + + it("bug-filed routes sessionID + url to the sink (the browser-fallback token included)", () => { + const { host, filed } = ioWithSink(); + expect( + handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-filed", sessionID: "ses_1", url: "https://github.com/x/issues/1" }, host), + ).toBe(true); + expect( + handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-filed", sessionID: "ses_2", url: "filed-via-browser" }, host), + ).toBe(true); + expect(filed).toEqual([ + { sessionID: "ses_1", url: "https://github.com/x/issues/1" }, + { sessionID: "ses_2", url: "filed-via-browser" }, + ]); + }); + + it("bug-report-closed routes the sessionID to the sink", () => { + const { host, closed } = ioWithSink(); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-report-closed", sessionID: "ses_9" }, host)).toBe(true); + expect(closed).toEqual(["ses_9"]); + }); + + it("malformed lifecycle envelopes are consumed and dropped — the sink never fires", () => { + const { host, filed, closed } = ioWithSink(); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-filed", url: "https://x.test/1" }, host)).toBe(true); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-filed", sessionID: 7, url: "u" }, host)).toBe(true); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-report-closed" }, host)).toBe(true); + expect(filed).toEqual([]); + expect(closed).toEqual([]); + }); + + it("without a sink the kinds are still consumed (never fall through to foreign-envelope logging)", () => { + const host = io(); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-filed", sessionID: "ses_1", url: "u" }, host)).toBe(true); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-report-closed", sessionID: "ses_1" }, host)).toBe(true); + }); +}); From 2d3544bbe9a80b6d455330523abe29a3fb329ad0 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 3 Aug 2026 19:43:30 -0400 Subject: [PATCH 02/22] =?UTF-8?q?test(bug-report):=20lifecycle=20=E2=80=94?= =?UTF-8?q?=20archive-on-filed,=20abort+delete=20on=20abandon=20(amicode#2?= =?UTF-8?q?50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/extension/test/bug_report.test.ts | 52 ++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/extension/test/bug_report.test.ts b/packages/extension/test/bug_report.test.ts index 9a6ab3f..03b64a7 100644 --- a/packages/extension/test/bug_report.test.ts +++ b/packages/extension/test/bug_report.test.ts @@ -113,3 +113,55 @@ describe("amicode.reportBug — create, arm, open (AC1)", () => { expect(new Set(authed)).toEqual(new Set([BOOT_AUTH])); }); }); + +/** Drive a manager to the open state with bug session `ses_bug`; returns the + * recorded traffic for the lifecycle assertions. */ +async function openBugSession(overrides: Partial = {}) { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "POST /session": { status: 200, body: { id: "ses_bug" } }, + "POST /session/ses_bug/command": { status: 200, body: {} }, + "PATCH /session/ses_bug": { status: 200, body: { id: "ses_bug" } }, + "POST /session/ses_bug/abort": { status: 200, body: true }, + "DELETE /session/ses_bug": { status: 200, body: true }, + }); + const { d, posted, errors } = deps(overrides, fetchImpl); + const manager = new BugReportManager(d); + await manager.reportBug(); + calls.length = 0; + posted.length = 0; + return { manager, calls, posted, errors }; +} + +describe("bug-session lifecycle (AC2)", () => { + it("bug-filed for the known id archives the session (soft hide) and tells the app to close the dock", async () => { + const { manager, calls, posted } = await openBugSession(); + + manager.sink.filed("ses_bug", "https://github.com/harmoniqs/amicode/issues/251"); + await new Promise((r) => setTimeout(r, 0)); + + const archive = calls.filter((c) => c.method === "PATCH" && c.url.endsWith("/session/ses_bug")); + expect(archive).toHaveLength(1); + const archived = (archive[0].body as { time: { archived: unknown } }).time.archived; + expect(typeof archived).toBe("number"); // ms epoch — the soft-hide timestamp + // No abort, no delete on the filed path — the transcript stays restorable. + expect(calls.filter((c) => c.method === "DELETE")).toEqual([]); + expect(calls.filter((c) => c.url.endsWith("/abort"))).toEqual([]); + expect(posted).toEqual([ + { source: "amicode", kind: "close-bug-report", sessionID: "ses_bug" }, + ]); + }); + + it("bug-report-closed before any filing aborts, then deletes, the session", async () => { + const { manager, calls, posted } = await openBugSession(); + + manager.sink.closed("ses_bug"); + await new Promise((r) => setTimeout(r, 0)); + + const methods = calls.map((c) => `${c.method} ${new URL(c.url).pathname}`); + expect(methods).toEqual(["POST /session/ses_bug/abort", "DELETE /session/ses_bug"]); + // Never archived on the abandon path; the dock already dismissed itself. + expect(calls.filter((c) => c.method === "PATCH")).toEqual([]); + expect(posted).toEqual([]); + }); +}); From 9e54c737c896662b80aa6fcd293b321fe9dc0388 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 3 Aug 2026 19:44:03 -0400 Subject: [PATCH 03/22] =?UTF-8?q?test(bug-report):=20single-open=20invaria?= =?UTF-8?q?nt=20=E2=80=94=20sequential=20reveal=20+=20concurrent=20join=20?= =?UTF-8?q?(amicode#250)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/extension/test/bug_report.test.ts | 51 ++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/packages/extension/test/bug_report.test.ts b/packages/extension/test/bug_report.test.ts index 03b64a7..27af7f2 100644 --- a/packages/extension/test/bug_report.test.ts +++ b/packages/extension/test/bug_report.test.ts @@ -165,3 +165,54 @@ describe("bug-session lifecycle (AC2)", () => { expect(posted).toEqual([]); }); }); + +describe("single-open invariant (AC3)", () => { + it("a second invocation while a bug session is open reveals with the SAME id — never a second create", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "POST /session": { status: 200, body: { id: "ses_bug" } }, + "POST /session/ses_bug/command": { status: 200, body: {} }, + }); + const { d, posted } = deps({}, fetchImpl); + const manager = new BugReportManager(d); + + await manager.reportBug(); + await manager.reportBug(); + await manager.reportBug(); + + expect(calls.filter((c) => c.method === "POST" && new URL(c.url).pathname === "/session")).toHaveLength(1); + expect(posted).toEqual([ + { source: "amicode", kind: "open-bug-report", sessionID: "ses_bug" }, + { source: "amicode", kind: "open-bug-report", sessionID: "ses_bug" }, + { source: "amicode", kind: "open-bug-report", sessionID: "ses_bug" }, + ]); + }); + + it("concurrent invocations join the in-flight open — exactly one session created", async () => { + let releaseCreate: (() => void) | undefined; + const gate = new Promise((r) => (releaseCreate = r)); + const calls: Call[] = []; + const fetchImpl = (async (input: string | URL, init?: RequestInit) => { + const url = String(input); + const path = new URL(url).pathname; + calls.push({ method: init?.method ?? "GET", url }); + if (init?.method === "POST" && path === "/session") await gate; // hold the create + const body = path === "/session" && init?.method === "POST" ? { id: "ses_bug" } : []; + return { ok: true, status: 200, json: async () => body } as Response; + }) as unknown as typeof fetch; + const { d, posted } = deps({}, fetchImpl); + const manager = new BugReportManager(d); + + const first = manager.reportBug(); + const second = manager.reportBug(); + releaseCreate!(); + await Promise.all([first, second]); + + expect(calls.filter((c) => c.method === "POST" && new URL(c.url).pathname === "/session")).toHaveLength(1); + // Both callers end at the open dock: one create-post, one reveal-post. + expect(posted).toEqual([ + { source: "amicode", kind: "open-bug-report", sessionID: "ses_bug" }, + { source: "amicode", kind: "open-bug-report", sessionID: "ses_bug" }, + ]); + }); +}); From 99f3cc9c1ecac96efaa92a34057f32e009488f04 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 3 Aug 2026 19:44:59 -0400 Subject: [PATCH 04/22] test(bug-report): failure cleanup, unknown-id drops, terminal latches (amicode#250) --- packages/extension/test/bug_report.test.ts | 91 ++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/packages/extension/test/bug_report.test.ts b/packages/extension/test/bug_report.test.ts index 27af7f2..3de6d2a 100644 --- a/packages/extension/test/bug_report.test.ts +++ b/packages/extension/test/bug_report.test.ts @@ -216,3 +216,94 @@ describe("single-open invariant (AC3)", () => { ]); }); }); + +describe("failure cleanup + unknown-id drops (AC4)", () => { + it("an arming failure deletes the partial session and surfaces an error — no orphan, no open message", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "POST /session": { status: 200, body: { id: "ses_bug" } }, + "POST /session/ses_bug/command": { status: 500, body: { error: "boom" } }, + "DELETE /session/ses_bug": { status: 200, body: true }, + }); + const { d, posted, errors } = deps({}, fetchImpl); + + await new BugReportManager(d).reportBug(); + + expect(calls.filter((c) => c.method === "DELETE" && c.url.endsWith("/session/ses_bug"))).toHaveLength(1); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("couldn't start the bug report"); + expect(posted).toEqual([]); + }); + + it("a create failure surfaces an error and never opens; a network throw on create also notifies", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "POST /session": { status: 503, body: {} }, + }); + const { d, posted, errors } = deps({}, fetchImpl); + + await new BugReportManager(d).reportBug(); + + expect(errors).toHaveLength(1); + expect(posted).toEqual([]); + // Nothing was created — there is no id to delete, and none is attempted. + expect(calls.filter((c) => c.method === "DELETE")).toEqual([]); + + const throwing = (async () => { + throw new Error("ECONNREFUSED"); + }) as unknown as typeof fetch; + const second = deps({}, throwing); + await new BugReportManager(second.d).reportBug(); + expect(second.errors).toHaveLength(1); + expect(second.posted).toEqual([]); + }); + + it("with the server down the command fails actionably and touches nothing", async () => { + const { fetchImpl, calls } = mockFetch({}); + const { d, posted, errors } = deps({ server: () => undefined }, fetchImpl); + + await new BugReportManager(d).reportBug(); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("isn't ready"); + expect(calls).toEqual([]); + expect(posted).toEqual([]); + }); + + it("bridge messages for unknown session ids are dropped — no archive, no abort, no delete, no down-post", async () => { + const { manager, calls, posted } = await openBugSession(); + + manager.sink.filed("ses_someone_else", "https://github.com/x/issues/9"); + manager.sink.closed("ses_someone_else"); + await new Promise((r) => setTimeout(r, 0)); + + expect(calls).toEqual([]); + expect(posted).toEqual([]); + }); + + it("a bug-report-closed arriving AFTER filing (the filed end-state's close) is already-terminal — dropped", async () => { + const { manager, calls, posted } = await openBugSession(); + + manager.sink.filed("ses_bug", "filed-via-browser"); + await new Promise((r) => setTimeout(r, 0)); + calls.length = 0; + posted.length = 0; + + manager.sink.closed("ses_bug"); // late close for the archived session + await new Promise((r) => setTimeout(r, 0)); + + expect(calls).toEqual([]); // no abort, no delete — the filed session is never hard-deleted + expect(posted).toEqual([]); + }); + + it("a duplicate bug-filed for the same id archives exactly once", async () => { + const { manager, calls, posted } = await openBugSession(); + + manager.sink.filed("ses_bug", "https://github.com/x/issues/1"); + manager.sink.filed("ses_bug", "https://github.com/x/issues/1"); + await new Promise((r) => setTimeout(r, 0)); + + expect(calls.filter((c) => c.method === "PATCH")).toHaveLength(1); + expect(posted).toHaveLength(1); + }); +}); From 642a80e58ca098e3a33ccea5ca5da4270e00f565 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 3 Aug 2026 19:47:17 -0400 Subject: [PATCH 05/22] feat(bug-report): boot-param gate, relay lanes, postToApp down-lane, sink wiring (amicode#250) --- packages/extension/src/chat_panel.ts | 37 ++++++++++++++++-- packages/extension/src/deck_panel.ts | 5 +++ packages/extension/test/bug_report.test.ts | 16 +++++++- packages/extension/test/chat_panel.test.ts | 44 ++++++++++++++++++++++ 4 files changed, 97 insertions(+), 5 deletions(-) diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index 518729c..6d456f7 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { randomBytes } from "node:crypto"; import { handleAmicodeBridgeMessage } from "./chat_bridge"; +import { getBugReport } from "./bug_report"; // ============================================================================ // ChatPanel — a WebviewPanel that iframes opencode's SolidJS chat at @@ -40,6 +41,10 @@ export class ChatPanel { private static current?: ChatPanel; /** Every live chat tab (primary included) — drives tab-title numbering. */ private static readonly live = new Set(); + /** The `amicode_bug_report=1` boot-param gate (amicode#250 AC5): set from the + * staged skill set after every session prep; the composer button renders + * only when the report-a-bug skill is there to answer it. */ + private static bugReportAvailable = false; private readonly disposables: vscode.Disposable[] = []; private constructor( @@ -72,6 +77,10 @@ export class ChatPanel { const handled = handleAmicodeBridgeMessage(msg, { visible: () => this.panel.visible, postToWebview: (m) => void this.panel.webview.postMessage(m), + // Bug-session lifecycle (#250): the dock's bug-filed / + // bug-report-closed route to the window's manager (undefined until + // activation registers it; the bridge consumes the kinds regardless). + bugReport: getBugReport()?.sink, }); if (!handled) console.log("[amicode/chat] webview msg:", msg); }, @@ -95,6 +104,22 @@ export class ChatPanel { setTimeout(() => void this.panel.webview.postMessage(envelope), 1500); } + /** AC5's gate setter — called after each session prep with + * bugReportSkillStaged(project.skillPaths). */ + static setBugReportAvailable(available: boolean): void { + ChatPanel.bugReportAvailable = available; + } + + /** DOWN lane for the bug-report dock (amicode#250): open-bug-report / + * close-bug-report. Same idiom as postComputeConnect — posted twice (now + + * 1.5s) because a freshly created panel's iframe may not be listening yet; + * both kinds are idempotent app-side (same-id open = reveal, close of a + * closed dock = no-op), so the re-post is pure reliability. */ + postToApp(envelope: { source: "amicode"; kind: string; sessionID: string }): void { + void this.panel.webview.postMessage(envelope); + setTimeout(() => void this.panel.webview.postMessage(envelope), 1500); + } + /** Lowest free tab label: the lone tab reads "Amicode Chat"; extras take the * smallest unused "Amicode Chat N" (N ≥ 2). Numbers free up on dispose, so a * closed tab's number is reused — existing tabs are never retitled. */ @@ -183,6 +208,10 @@ export class ChatPanel { // phantom project. Only amicode sets this — standalone opencode is // unaffected (its cwd IS the user's project). if (hideProjectDir) framed.searchParams.set("amicode_hide_project", hideProjectDir); + // amicode#250 AC5: arm the composer's report-a-bug button ONLY when the + // staged skill set includes report-a-bug. The dock iframe (pane bug-dock) + // never carries this — it is the main app surface's gate alone. + if (ChatPanel.bugReportAvailable) framed.searchParams.set("amicode_bug_report", "1"); return /* html */ ` @@ -216,15 +245,15 @@ export class ChatPanel { replyClipboardImage(d.nonce); return; } - if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "save-file" || d.kind === "set-default-model")) { + if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed")) { vscode.postMessage(d); } return; } - // Lane 2 — extension → iframe (theme): posted by the extension host + // Lane 2 — extension → iframe: posted by the extension host // (webview-internal origin, never the opencode origin). Forward only - // our own theme envelope, pinned to the opencode origin. - if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "open-compute-connect")) { + // our own envelopes, pinned to the opencode origin. + if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report")) { var f = document.querySelector("iframe"); if (f && f.contentWindow) f.contentWindow.postMessage(d, ${origin}); } diff --git a/packages/extension/src/deck_panel.ts b/packages/extension/src/deck_panel.ts index 2e17dbb..732513f 100644 --- a/packages/extension/src/deck_panel.ts +++ b/packages/extension/src/deck_panel.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; import { randomBytes } from "node:crypto"; import { handleAmicodeBridgeMessage } from "./chat_bridge"; import { tabIconPath, themeKindToScheme } from "./chat_panel"; +import { getBugReport } from "./bug_report"; // ============================================================================ // DeckPanel — the Chat Deck: MANY chat panes inside ONE editor tab. The heavy @@ -46,6 +47,10 @@ export class DeckPanel { const handled = handleAmicodeBridgeMessage(msg, { visible: () => this.panel.visible, postToWebview: (m) => void this.panel.webview.postMessage(m), + // Bug-session lifecycle (#250) — deck panes never carry the + // amicode_bug_report boot param, so no dock lives here; wired for + // uniformity (the manager drops unknown ids anyway). + bugReport: getBugReport()?.sink, }); if (!handled) console.log("[amicode/deck] webview msg:", msg); }, diff --git a/packages/extension/test/bug_report.test.ts b/packages/extension/test/bug_report.test.ts index 3de6d2a..1c81333 100644 --- a/packages/extension/test/bug_report.test.ts +++ b/packages/extension/test/bug_report.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { BugReportManager, type BugReportDeps } from "../src/bug_report"; +import { BugReportManager, bugReportSkillStaged, type BugReportDeps } from "../src/bug_report"; // ============================================================================ // amicode#250 — the extension owns the bug session end-to-end: create (title @@ -166,6 +166,20 @@ describe("bug-session lifecycle (AC2)", () => { }); }); +describe("bugReportSkillStaged — the boot-param gate (AC5)", () => { + it("true iff a staged skill path belongs to report-a-bug (library or package layout)", () => { + expect( + bugReportSkillStaged([ + "/ext/vendor/skills-public/skills/transmon/SKILL.md", + "/ext/vendor/skills-public/skills/report-a-bug/SKILL.md", + ]), + ).toBe(true); + expect(bugReportSkillStaged(["/home/dev/harmoniqs/packages/Piccolissimo.jl/skills/report-a-bug/SKILL.md"])).toBe(true); + expect(bugReportSkillStaged(["/ext/vendor/skills-public/skills/transmon/SKILL.md"])).toBe(false); + expect(bugReportSkillStaged([])).toBe(false); + }); +}); + describe("single-open invariant (AC3)", () => { it("a second invocation while a bug session is open reveals with the SAME id — never a second create", async () => { const { fetchImpl, calls } = mockFetch({ diff --git a/packages/extension/test/chat_panel.test.ts b/packages/extension/test/chat_panel.test.ts index 6a48e78..0b1744e 100644 --- a/packages/extension/test/chat_panel.test.ts +++ b/packages/extension/test/chat_panel.test.ts @@ -74,3 +74,47 @@ describe("ChatPanel — auth_token carriage to the fork app (#163)", () => { expect(iframeSrc(cap.created[0].webview.html).searchParams.has("auth_token")).toBe(false); }); }); + +describe("ChatPanel — the amicode_bug_report boot param (amicode#250 AC5)", () => { + let restore: (() => void) | undefined; + let created: CapturedPanel[] = []; + afterEach(() => { + for (const p of created) p.dispose(); + restore?.(); + restore = undefined; + created = []; + ChatPanel.setBugReportAvailable(false); // static feature flag — reset between tests + }); + + it("sets amicode_bug_report=1 on the iframe src when the staged skills include report-a-bug", () => { + const cap = capturePanel(); + restore = cap.restore; + created = cap.created; + ChatPanel.setBugReportAvailable(true); + ChatPanel.openOrReveal(fakeCtx(), new URL("http://127.0.0.1:43117/")); + expect(iframeSrc(cap.created[0].webview.html).searchParams.get("amicode_bug_report")).toBe("1"); + }); + + it("omits the param when report-a-bug was not staged — no dead button on Marketplace installs", () => { + const cap = capturePanel(); + restore = cap.restore; + created = cap.created; + ChatPanel.setBugReportAvailable(false); + ChatPanel.openOrReveal(fakeCtx(), new URL("http://127.0.0.1:43117/")); + expect(iframeSrc(cap.created[0].webview.html).searchParams.has("amicode_bug_report")).toBe(false); + }); + + it("the relay admits the bug-report kinds on both lanes", () => { + const cap = capturePanel(); + restore = cap.restore; + created = cap.created; + ChatPanel.openOrReveal(fakeCtx(), new URL("http://127.0.0.1:43117/")); + const html = cap.created[0].webview.html; + // Lane 1 (app → extension): the dock's lifecycle reports. + expect(html).toContain('"bug-filed"'); + expect(html).toContain('"bug-report-closed"'); + // Lane 2 (extension → app): dock open/close. + expect(html).toContain('"open-bug-report"'); + expect(html).toContain('"close-bug-report"'); + }); +}); From 7dc4da1285dda117ffaabc3cd54c3a545edea209 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 3 Aug 2026 19:48:31 -0400 Subject: [PATCH 06/22] test(bug-report): origin session is read-only provenance across every path (amicode#250) --- packages/extension/test/bug_report.test.ts | 56 ++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/packages/extension/test/bug_report.test.ts b/packages/extension/test/bug_report.test.ts index 1c81333..36ac742 100644 --- a/packages/extension/test/bug_report.test.ts +++ b/packages/extension/test/bug_report.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import * as vscode from "vscode"; import { BugReportManager, bugReportSkillStaged, type BugReportDeps } from "../src/bug_report"; // ============================================================================ @@ -166,6 +167,61 @@ describe("bug-session lifecycle (AC2)", () => { }); }); +describe("the originating session is never modified, navigated, or closed (AC6)", () => { + it("no path mutates the origin session — it is only ever READ (the list call) and embedded as a pointer", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { + status: 200, + body: [ + { id: "ses_origin", time: { created: 1, updated: 9 } }, + { id: "ses_older", time: { created: 1, updated: 2 } }, + ], + }, + "POST /session": { status: 200, body: { id: "ses_bug" } }, + "POST /session/ses_bug/command": { status: 200, body: {} }, + "PATCH /session/ses_bug": { status: 200, body: { id: "ses_bug" } }, + "POST /session/ses_bug/abort": { status: 200, body: true }, + "DELETE /session/ses_bug": { status: 200, body: true }, + }); + const { d, posted } = deps({}, fetchImpl); + const manager = new BugReportManager(d); + + // Full lifecycle coverage: open → filed; open again → abandoned. + await manager.reportBug(); + manager.sink.filed("ses_bug", "https://github.com/harmoniqs/amicode/issues/251"); + await new Promise((r) => setTimeout(r, 0)); + await manager.reportBug(); + manager.sink.closed("ses_bug"); + await new Promise((r) => setTimeout(r, 0)); + + // The envelope DID carry the origin pointer (provenance works)… + const creates = calls.filter((c) => c.method === "POST" && new URL(c.url).pathname === "/session"); + for (const create of creates) { + expect((create.body as { metadata: { bug_report: { origin_session_id?: string } } }).metadata.bug_report.origin_session_id).toBe( + "ses_origin", + ); + } + // …but the only call that so much as NAMES a non-bug session is the + // read-only list. Every mutation targets the bug session alone. + const foreign = calls.filter((c) => c.url.includes("ses_origin") || c.url.includes("ses_older")); + expect(foreign).toEqual([]); + for (const c of calls.filter((c) => c.method !== "GET")) { + // POST /session is the bug-session CREATE (the one session we may make); + // every other mutation carries the bug id and nothing else. + expect(new URL(c.url).pathname).toMatch(/^\/session(\/ses_bug(\/command|\/abort)?)?$/); + } + // And nothing reaches for a navigation/session-switch command — the main + // chat continues uninterrupted while the dock lives (and dies). + expect((vscode.commands as unknown as { executed: string[] }).executed ?? []).toEqual([]); + // The down lane carries ONLY dock open/close for the bug session — never + // an instruction about the origin. + for (const m of posted) { + expect((m as { sessionID: string }).sessionID).toBe("ses_bug"); + expect(["open-bug-report", "close-bug-report"]).toContain((m as { kind: string }).kind); + } + }); +}); + describe("bugReportSkillStaged — the boot-param gate (AC5)", () => { it("true iff a staged skill path belongs to report-a-bug (library or package layout)", () => { expect( From 1dec4d8a5e6242a824b55bb35883661030431a1e Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 3 Aug 2026 19:52:36 -0400 Subject: [PATCH 07/22] feat(bug-report): register amicode.reportBug, wire the manager + boot-param gate at every session prep (amicode#250) --- packages/extension/package.json | 5 +++ packages/extension/src/chat_panel.ts | 6 +++ packages/extension/src/extension.ts | 43 ++++++++++++++++++++ packages/extension/src/runs_manager.ts | 7 ++++ packages/extension/test/runs_manager.test.ts | 15 +++++++ 5 files changed, 76 insertions(+) diff --git a/packages/extension/package.json b/packages/extension/package.json index 5df645c..ab1e53c 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -104,6 +104,11 @@ "title": "Amicode: Open Chat Deck (Panes in One Tab)", "icon": "$(layout)" }, + { + "command": "amicode.reportBug", + "title": "Amicode: Report a Bug", + "icon": "$(bug)" + }, { "command": "amicode.setupVault", "title": "Amicode: Set up a personal vault" diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index 6d456f7..b975f3b 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -110,6 +110,12 @@ export class ChatPanel { ChatPanel.bugReportAvailable = available; } + /** The primary panel if one is live (never creates) — the down lane's + * fallback when the server is mid-restart and no ready URL exists. */ + static peek(): ChatPanel | undefined { + return ChatPanel.current; + } + /** DOWN lane for the bug-report dock (amicode#250): open-bug-report / * close-bug-report. Same idiom as postComputeConnect — posted twice (now + * 1.5s) because a freshly created panel's iframe may not be listening yet; diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index fbc3920..8de8736 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -54,6 +54,12 @@ import { import { probeCommand, formatHealthReport, type HealthResult } from "./healthcheck"; import { resolveMountStack, personalMount, defaultVaultsRoot } from "./substrate/mount_store"; import { initDistillerTransport, triggerRunDistill, triggerSweep, type DistillerSetup } from "./substrate/distiller"; +import { + registerBugReport, + unregisterBugReport, + bugReportSkillStaged, + REPORT_BUG_COMMAND, +} from "./bug_report"; import * as os from "node:os"; import { readTomlSafe } from "./run_dir_reader"; import { parse as parseYaml } from "yaml"; @@ -451,6 +457,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { opencodeChannel.appendLine(`[boot] opencode project dir: ${opencodeProject.projectDir}`); opencodeChannel.appendLine(`[boot] AGENTS.md: ${opencodeProject.agentsPath}`); opencodeChannel.appendLine(`[boot] template: ${opencodeProject.templatePath}`); + // amicode#250 AC5: the composer bug button gates on the staged skill set — + // re-pinned after EVERY session prep (boot, solver switch, vault respawn). + ChatPanel.setBugReportAvailable(bugReportSkillStaged(opencodeProject.skillPaths)); opencodeChannel.appendLine( `[boot] armonia mounts: ${opencodeProject.mounts.length} (${opencodeProject.mounts.map((m) => m.name).join(", ")})`, ); @@ -503,6 +512,34 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // (via the app's ?auth_token= bootstrap). const serverAuthHeaders = { Authorization: serverAuthHeader(serverPassword) }; + // Bug-report orchestration (amicode#250, ADR 0004): the window's ONE + // BugReportManager — owns the bug session's id end-to-end (create / arm / + // open), the machine-managed lifecycle (archive-on-filed, abort+delete on + // abandon), and the single-open invariant. Deps are closures over live + // activation state (ready URL, runs manager), so respawns need no rewire. + const bugReport = registerBugReport({ + server: () => + opencodeReadyUrl ? { url: opencodeReadyUrl.toString(), authorization: serverAuthHeaders.Authorization } : undefined, + workspaceDir: () => vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, + activeRunPointer: () => runsManager?.getActiveRunPointer(), + postDown: (msg) => { + // The dock lives in the main app surface — ensure a chat panel when we + // can (palette invocation with none open), else post to the live one. + const url = opencodeReadyUrl; + const panel = url + ? ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir) + : ChatPanel.peek(); + if (!panel) { + opencodeChannel.appendLine("[bug] no chat surface for the dock message — dropped"); + return; + } + panel.postToApp(msg as { source: "amicode"; kind: string; sessionID: string }); + }, + showError: (m) => void vscode.window.showErrorMessage(m), + log: (line) => opencodeChannel.appendLine(line), + }); + ctx.subscriptions.push({ dispose: () => unregisterBugReport() }); + if (binary !== undefined) { // amico-run is argv-only (β.1) — no AMICO_* env propagation (S37), with ONE // recorded exception: AMICO_PYTHON (Pasqal python provisioning) rides the @@ -588,6 +625,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { skillLibraryRoots: cfgLibraryRoots(), vaultDir: vscode.workspace.getConfiguration("amicode").get("vaultDir", "") || undefined, }); + ChatPanel.setBugReportAvailable(bugReportSkillStaged(project2.skillPaths)); // #250 AC5 await serverManager?.stop(); serverManager = new ServerManager({ binary: binary!, @@ -719,6 +757,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vaultDir: vscode.workspace.getConfiguration("amicode").get("vaultDir", "") || undefined, projectDir: path.join((ctx.storageUri ?? ctx.globalStorageUri).fsPath, "opencode-project"), }); + ChatPanel.setBugReportAvailable(bugReportSkillStaged(project2.skillPaths)); // #250 AC5 await serverManager.stop(); serverManager = new ServerManager({ binary, @@ -1083,6 +1122,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } DeckPanel.openOrReveal(ctx, readyUrl, serverAuthToken(serverPassword), opencodeProject.projectDir); }), + // Report a Bug (amicode#250): the palette entry + the composer bug button's + // bridge command share this one handler — the manager owns create/arm/open, + // the lifecycle, and the single-open invariant. + vscode.commands.registerCommand(REPORT_BUG_COMMAND, () => void bugReport.reportBug()), vscode.commands.registerCommand("amicode.openInspector", async () => { await revealInspector(); }), diff --git a/packages/extension/src/runs_manager.ts b/packages/extension/src/runs_manager.ts index fd2fad8..5191b17 100644 --- a/packages/extension/src/runs_manager.ts +++ b/packages/extension/src/runs_manager.ts @@ -308,6 +308,13 @@ export class RunsManager implements vscode.Disposable { return this.selected ? this.registry.get(this.selected)?.runDir : undefined; } + /** The active run as a POINTER (amicode#250's bug-report envelope): the + * registry runId — relative to the runs root by construction, never an + * absolute path. undefined when no run is selected. */ + getActiveRunPointer(): string | undefined { + return this.selected ? this.registry.get(this.selected)?.runId : undefined; + } + /** Release an explicit pin and resume latest-follow: jump to the newest LIVE * run if one exists (registration order = creation order), else stay put. * Backs the run picker's "Follow latest" entry. */ diff --git a/packages/extension/test/runs_manager.test.ts b/packages/extension/test/runs_manager.test.ts index b2d781e..7100967 100644 --- a/packages/extension/test/runs_manager.test.ts +++ b/packages/extension/test/runs_manager.test.ts @@ -122,6 +122,21 @@ describe("RunsManager state machine (ported from RunsRootWatcher)", () => { m.dispose(); }); + it("getActiveRunPointer is the selected run's runId (relative pointer, never an absolute path) or undefined (amicode#250)", () => { + const root = mkdtempSync(join(tmpdir(), "runs-")); + const m = new RunsManager({ runsRoot: root, channel }); + m.start(); + expect(m.getActiveRunPointer()).toBeUndefined(); // nothing tracked yet + stageRun(root, "default/20260803-104655-x-gate"); + tick(m); + expect(m.selectedRun).toBe("default/20260803-104655-x-gate"); + const pointer = m.getActiveRunPointer(); + expect(pointer).toBe("default/20260803-104655-x-gate"); + expect(pointer).not.toContain(root); // pointer form only — the bug-report envelope + expect(pointer!.startsWith("/")).toBe(false); + m.dispose(); + }); + it("live tail forwards meta and each record in order as they land (#66), runId-tagged", () => { const root = mkdtempSync(join(tmpdir(), "runs-")); const run = stageRun(root, "p1"); From ca33bf1b84b9197f263b832ff2684a2e433478d3 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 3 Aug 2026 19:54:53 -0400 Subject: [PATCH 08/22] refactor(bug-report): one scoped collection URL, typed down-message, bounded lifecycle logging (amicode#250) --- packages/extension/src/bug_report.ts | 33 +++++++++++++++++++--------- packages/extension/src/extension.ts | 2 +- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/extension/src/bug_report.ts b/packages/extension/src/bug_report.ts index 669f722..d15b04d 100644 --- a/packages/extension/src/bug_report.ts +++ b/packages/extension/src/bug_report.ts @@ -38,6 +38,13 @@ export const CLOSE_BUG_REPORT_KIND = "close-bug-report"; export const BUG_FILED_KIND = "bug-filed"; export const BUG_REPORT_CLOSED_KIND = "bug-report-closed"; +/** The DOWN envelopes this module ever posts. */ +export interface BugReportDownMessage { + source: "amicode"; + kind: typeof OPEN_BUG_REPORT_KIND | typeof CLOSE_BUG_REPORT_KIND; + sessionID: string; +} + /** The `amicode_bug_report=1` boot param is set iff the staged skill set * includes report-a-bug (AC5) — the composer button never renders without the * skill that answers it. Staged paths are `//SKILL.md` and the @@ -63,7 +70,7 @@ export interface BugReportDeps { * relative to the runs root, never an absolute path; undefined when none. */ activeRunPointer(): string | undefined; /** DOWN lane to the main app surface (the dock's host). */ - postDown(msg: unknown): void; + postDown(msg: BugReportDownMessage): void; showError(message: string): void; log?(line: string): void; fetchImpl?: typeof fetch; @@ -154,16 +161,24 @@ export class BugReportManager { return envelope; } + /** Session-collection URL scoped to the app's project (the VS Code workspace + * folder) — without it the server defaults to its OWN cwd scope, where the + * user's sessions don't live. Member routes (/session/:id/…) need no scope: + * the server routes those by the session's own directory. */ + private collectionUrl(server: BugReportServer): URL { + const url = new URL("/session", server.url); + const dir = this.deps.workspaceDir(); + if (dir) url.searchParams.set("directory", dir); + return url; + } + /** The originating session, best-effort: the most recently updated root * session in the app's project scope that isn't itself a bug session (the * list is updated-DESC and excludes archived). READ-ONLY provenance — this * id is never a mutation target (AC6). undefined when unknowable. */ private async findOriginSession(server: BugReportServer): Promise { try { - const url = new URL("/session", server.url); - const dir = this.deps.workspaceDir(); - if (dir) url.searchParams.set("directory", dir); - const res = await this.fetch(url, server, { method: "GET" }); + const res = await this.fetch(this.collectionUrl(server), server, { method: "GET" }); if (!res.ok) return undefined; const sessions = (await res.json()) as Array<{ id?: unknown; @@ -184,10 +199,7 @@ export class BugReportManager { } private async createSession(server: BugReportServer, envelope: Record): Promise { - const url = new URL("/session", server.url); - const dir = this.deps.workspaceDir(); - if (dir) url.searchParams.set("directory", dir); - const res = await this.fetch(url, server, { + const res = await this.fetch(this.collectionUrl(server), server, { method: "POST", body: { title: BUG_REPORT_TITLE, metadata: { bug_report: envelope } }, }); @@ -212,7 +224,8 @@ export class BugReportManager { private async onBugFiled(sessionID: string, url: string): Promise { if (sessionID !== this.current) return; this.current = undefined; // terminal latch: later messages for this id are unknown - this.deps.log?.(`[bug] filed (${url}) — archiving ${sessionID}`); + // The url rides the log only; it is app-supplied (LLM-adjacent) — bound it. + this.deps.log?.(`[bug] filed (${url.slice(0, 300)}) — archiving ${sessionID}`); const server = this.deps.server(); if (server) { try { diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 8de8736..18d94b2 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -533,7 +533,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { opencodeChannel.appendLine("[bug] no chat surface for the dock message — dropped"); return; } - panel.postToApp(msg as { source: "amicode"; kind: string; sessionID: string }); + panel.postToApp(msg); }, showError: (m) => void vscode.window.showErrorMessage(m), log: (line) => opencodeChannel.appendLine(line), From 5c191a3d2d81e85a536c536d5e9c9ec09f228b86 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 3 Aug 2026 22:02:23 -0400 Subject: [PATCH 09/22] =?UTF-8?q?fix(extension):=20bug-report-poke=20up-ki?= =?UTF-8?q?nd=20=E2=80=94=20re-post=20open-bug-report=20on=20app=20boot=20?= =?UTF-8?q?(amicode#249=20QA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app pokes once per frame boot with the flag on; the manager answers with a fresh open-bug-report when a bug session is live (joining an in-flight create, never double-creating), silence otherwise. Also: [bug] lifecycle log lines for the open/poke paths — the preview debugging had to spelunk the db for what one log line would have said. --- packages/extension/src/bug_report.ts | 23 +++++++++ packages/extension/src/chat_bridge.ts | 7 +++ packages/extension/src/chat_panel.ts | 2 +- packages/extension/test/bug_report.test.ts | 56 +++++++++++++++++++++ packages/extension/test/chat_bridge.test.ts | 14 +++++- 5 files changed, 100 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/bug_report.ts b/packages/extension/src/bug_report.ts index d15b04d..f0d6e32 100644 --- a/packages/extension/src/bug_report.ts +++ b/packages/extension/src/bug_report.ts @@ -37,6 +37,11 @@ export const OPEN_BUG_REPORT_KIND = "open-bug-report"; export const CLOSE_BUG_REPORT_KIND = "close-bug-report"; export const BUG_FILED_KIND = "bug-filed"; export const BUG_REPORT_CLOSED_KIND = "bug-report-closed"; +/** UP: the app posts this on boot when the bug-report flag is on — the + * catch-up half of the open contract. A one-shot open-bug-report can land + * before the app's listener mounts (cold window, webview reload), so a live + * bug session re-opens its dock on every app boot until it terminates. */ +export const BUG_REPORT_POKE_KIND = "bug-report-poke"; /** The DOWN envelopes this module ever posts. */ export interface BugReportDownMessage { @@ -81,6 +86,7 @@ export interface BugReportDeps { export interface BugReportBridgeSink { filed(sessionID: string, url: string): void; closed(sessionID: string): void; + poke(): void; } export class BugReportManager { @@ -97,8 +103,24 @@ export class BugReportManager { readonly sink: BugReportBridgeSink = { filed: (sessionID, url) => void this.onBugFiled(sessionID, url), closed: (sessionID) => void this.onBugReportClosed(sessionID), + poke: () => void this.onPoke(), }; + /** The app's boot catch-up: it pokes on every boot with the flag on, so a + * lost open-bug-report (cold-boot race, webview reload) self-heals — a live + * bug session re-posts its open; no live session is silence (cheap, once + * per app frame boot). A poke during an in-flight create joins it, exactly + * like a second command invocation. */ + private async onPoke(): Promise { + if (this.opening) await this.opening; + if (!this.current) { + this.deps.log?.(`[bug] poke — no live bug session`); + return; + } + this.deps.log?.(`[bug] poke — re-opening the dock for ${this.current}`); + this.deps.postDown({ source: "amicode", kind: OPEN_BUG_REPORT_KIND, sessionID: this.current }); + } + /** The `amicode.reportBug` command: reveal the open bug session, else * create + arm + open a new one. Never two bug sessions. */ async reportBug(): Promise { @@ -147,6 +169,7 @@ export class BugReportManager { return; } this.current = sessionID; + this.deps.log?.(`[bug] opened ${sessionID} — posting open-bug-report`); this.deps.postDown({ source: "amicode", kind: OPEN_BUG_REPORT_KIND, sessionID }); } diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts index b2a6c60..b0f9dc0 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -36,6 +36,7 @@ export const BRIDGE_ALLOWED_COMMANDS: ReadonlySet = new Set([ export interface BugReportSink { filed(sessionID: string, url: string): void; closed(sessionID: string): void; + poke(): void; } /** Side channels the handler needs from its host panel. */ @@ -153,6 +154,12 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean if (typeof sessionID === "string" && sessionID !== "") io.bugReport?.closed(sessionID); return true; } + // The app's boot catch-up: re-post open-bug-report when a bug session is + // live (heals a lost one-shot open — cold-boot race, webview reload). + if (msg.kind === "bug-report-poke") { + io.bugReport?.poke(); + return true; + } // Dashboard "Default model" control mirrors its choice into the // amicode.defaultModel setting, so the config pin (headless / first turn) diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index b975f3b..7f1c7c5 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -251,7 +251,7 @@ export class ChatPanel { replyClipboardImage(d.nonce); return; } - if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed")) { + if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke")) { vscode.postMessage(d); } return; diff --git a/packages/extension/test/bug_report.test.ts b/packages/extension/test/bug_report.test.ts index 36ac742..0e65e04 100644 --- a/packages/extension/test/bug_report.test.ts +++ b/packages/extension/test/bug_report.test.ts @@ -377,3 +377,59 @@ describe("failure cleanup + unknown-id drops (AC4)", () => { expect(posted).toHaveLength(1); }); }); + +describe("bug-report-poke — the app's boot catch-up (QA: lost-open race)", () => { + it("a poke with a live bug session re-posts open-bug-report for it", async () => { + const { manager, posted } = await openBugSession(); + + manager.sink.poke(); + await new Promise((r) => setTimeout(r, 0)); + + expect(posted).toEqual([{ source: "amicode", kind: "open-bug-report", sessionID: "ses_bug" }]); + }); + + it("a poke with no live bug session is silence (no posts, no server calls)", async () => { + const { fetchImpl, calls } = mockFetch({}); + const { d, posted } = deps({}, fetchImpl); + + new BugReportManager(d).sink.poke(); + await new Promise((r) => setTimeout(r, 0)); + + expect(posted).toEqual([]); + expect(calls).toEqual([]); + }); + + it("a poke after the session terminated is silence (the dock stays gone)", async () => { + const { manager, posted } = await openBugSession(); + manager.sink.filed("ses_bug", "https://github.com/x/issues/1"); + await new Promise((r) => setTimeout(r, 0)); + posted.length = 0; + + manager.sink.poke(); + await new Promise((r) => setTimeout(r, 0)); + + expect(posted).toEqual([]); + }); + + it("a poke during an in-flight create joins it — one create, every open for the new id", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "POST /session": { status: 200, body: { id: "ses_bug" } }, + "POST /session/ses_bug/command": { status: 200, body: {} }, + }); + const { d, posted } = deps({}, fetchImpl); + const manager = new BugReportManager(d); + const opening = manager.reportBug(); // in flight — the poke must not double-create + manager.sink.poke(); + await opening; + await new Promise((r) => setTimeout(r, 0)); + + const creates = calls.filter((c) => c.method === "POST" && new URL(c.url).pathname === "/session"); + expect(creates).toHaveLength(1); + const opens = posted.filter((m) => (m as { kind?: unknown }).kind === "open-bug-report"); + // The open from create+arm plus the poke's re-post — same id, idempotent + // app-side (same-id open is a reveal). + expect(opens).toHaveLength(2); + expect(opens.every((m) => (m as { sessionID?: unknown }).sessionID === "ses_bug")).toBe(true); + }); +}); diff --git a/packages/extension/test/chat_bridge.test.ts b/packages/extension/test/chat_bridge.test.ts index 8f5d3b6..e8293c6 100644 --- a/packages/extension/test/chat_bridge.test.ts +++ b/packages/extension/test/chat_bridge.test.ts @@ -113,11 +113,15 @@ describe("amicode bridge — bug-report lifecycle kinds (amicode#250)", () => { const host = io(visible); const filed: Array<{ sessionID: string; url: string }> = []; const closed: string[] = []; + let pokes = 0; host.bugReport = { filed: (sessionID, url) => filed.push({ sessionID, url }), closed: (sessionID) => closed.push(sessionID), + poke: () => { + pokes += 1; + }, }; - return { host, filed, closed }; + return { host, filed, closed, pokes: () => pokes }; } it("bug-filed routes sessionID + url to the sink (the browser-fallback token included)", () => { @@ -140,6 +144,14 @@ describe("amicode bridge — bug-report lifecycle kinds (amicode#250)", () => { expect(closed).toEqual(["ses_9"]); }); + it("bug-report-poke routes to the sink's catch-up (consumed, payload-free)", () => { + const { host, pokes } = ioWithSink(); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-report-poke" }, host)).toBe(true); + expect(pokes()).toBe(1); + // Without a sink: still consumed, never foreign-noise. + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-report-poke" }, io())).toBe(true); + }); + it("malformed lifecycle envelopes are consumed and dropped — the sink never fires", () => { const { host, filed, closed } = ioWithSink(); expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-filed", url: "https://x.test/1" }, host)).toBe(true); From b01267b051d49527126d48bd5eb6fd20ea0e62a0 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 3 Aug 2026 22:48:35 -0400 Subject: [PATCH 10/22] =?UTF-8?q?fix(extension):=20zombie=20guard=20?= =?UTF-8?q?=E2=80=94=20reap=20orphaned=20bug=20sessions=20on=20close=20(am?= =?UTF-8?q?icode#249=20QA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sync watch can surface a bug session orphaned by a dead extension host. Closing that dock posted bug-report-closed for an id the new manager never knew; dropping it left an immortal session the watch resurrected forever. Unknown-id closes now read the session: bug_report metadata + not archived → abort + hard delete; archived (filed) sessions and genuinely foreign ids are never touched. --- packages/extension/src/bug_report.ts | 43 ++++++++++++++++++- packages/extension/test/bug_report.test.ts | 48 +++++++++++++++++++--- 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/packages/extension/src/bug_report.ts b/packages/extension/src/bug_report.ts index f0d6e32..2b46215 100644 --- a/packages/extension/src/bug_report.ts +++ b/packages/extension/src/bug_report.ts @@ -266,7 +266,17 @@ export class BugReportManager { /** Closed before filing → abort the in-flight turn, then hard delete. */ private async onBugReportClosed(sessionID: string): Promise { - if (sessionID !== this.current) return; + if (sessionID !== this.current) { + // Zombie guard (QA: amicode#249 preview): the dock's sync watch can + // surface a bug session ORPHANED by a dead extension host (killed + // mid-session — its manager state died with it). If the user closes + // that dock, dropping the message as "unknown id" would leave an + // immortal session the watch keeps resurrecting. The envelope is the + // ground truth: a session carrying bug_report metadata IS a bug + // session, so the abandon path applies — abort + hard delete. + await this.reapOrphan(sessionID); + return; + } this.current = undefined; const server = this.deps.server(); if (!server) return; @@ -280,6 +290,37 @@ export class BugReportManager { await this.deleteSession(server, sessionID); } + /** Close-of-unknown-id: reaps the session iff it proves to be a bug + * session (bug_report metadata) that is NOT archived — an archived one is + * filed and restorable, never a delete target. Genuinely foreign ids drop + * silently. */ + private async reapOrphan(sessionID: string): Promise { + const server = this.deps.server(); + if (!server) return; + let isBug = false; + try { + const res = await this.fetch(new URL(`/session/${sessionID}`, server.url), server, { method: "GET" }); + if (res.ok) { + const info = (await res.json()) as { metadata?: unknown; time?: { archived?: unknown } }; + isBug = + !!info.metadata && + typeof info.metadata === "object" && + "bug_report" in info.metadata && + !info.time?.archived; + } + } catch { + return; // a read failure never guesses at deletion + } + if (!isBug) return; + this.deps.log?.(`[bug] reaping orphaned bug session ${sessionID}`); + try { + await this.fetch(new URL(`/session/${sessionID}/abort`, server.url), server, { method: "POST" }); + } catch { + /* an idle orphan may have nothing to abort */ + } + await this.deleteSession(server, sessionID); + } + private async deleteSession(server: BugReportServer, sessionID: string): Promise { try { const res = await this.fetch(new URL(`/session/${sessionID}`, server.url), server, { method: "DELETE" }); diff --git a/packages/extension/test/bug_report.test.ts b/packages/extension/test/bug_report.test.ts index 0e65e04..60340a8 100644 --- a/packages/extension/test/bug_report.test.ts +++ b/packages/extension/test/bug_report.test.ts @@ -117,7 +117,7 @@ describe("amicode.reportBug — create, arm, open (AC1)", () => { /** Drive a manager to the open state with bug session `ses_bug`; returns the * recorded traffic for the lifecycle assertions. */ -async function openBugSession(overrides: Partial = {}) { +async function openBugSession(overrides: Partial = {}, extraRoutes: Record = {}) { const { fetchImpl, calls } = mockFetch({ "GET /session": { status: 200, body: [] }, "POST /session": { status: 200, body: { id: "ses_bug" } }, @@ -125,6 +125,7 @@ async function openBugSession(overrides: Partial = {}) { "PATCH /session/ses_bug": { status: 200, body: { id: "ses_bug" } }, "POST /session/ses_bug/abort": { status: 200, body: true }, "DELETE /session/ses_bug": { status: 200, body: true }, + ...extraRoutes, }); const { d, posted, errors } = deps(overrides, fetchImpl); const manager = new BugReportManager(d); @@ -347,12 +348,18 @@ describe("failure cleanup + unknown-id drops (AC4)", () => { manager.sink.closed("ses_someone_else"); await new Promise((r) => setTimeout(r, 0)); - expect(calls).toEqual([]); + // The zombie guard may READ an unknown closed session (metadata probe) — + // but a 404/foreign session is never mutated, and nothing posts down. + expect(calls.filter((c) => c.method !== "GET")).toEqual([]); expect(posted).toEqual([]); }); it("a bug-report-closed arriving AFTER filing (the filed end-state's close) is already-terminal — dropped", async () => { - const { manager, calls, posted } = await openBugSession(); + const { manager, calls, posted } = await openBugSession({}, { + // The session now reads archived (filed): the zombie guard must NOT + // reap it — archived bug sessions are restorable, never delete targets. + "GET /session/ses_bug": { status: 200, body: { id: "ses_bug", metadata: { bug_report: {} }, time: { archived: 1700000000000 } } }, + }); manager.sink.filed("ses_bug", "filed-via-browser"); await new Promise((r) => setTimeout(r, 0)); @@ -362,7 +369,7 @@ describe("failure cleanup + unknown-id drops (AC4)", () => { manager.sink.closed("ses_bug"); // late close for the archived session await new Promise((r) => setTimeout(r, 0)); - expect(calls).toEqual([]); // no abort, no delete — the filed session is never hard-deleted + expect(calls.filter((c) => c.method !== "GET")).toEqual([]); // no abort, no delete — the filed session is never hard-deleted expect(posted).toEqual([]); }); @@ -378,8 +385,7 @@ describe("failure cleanup + unknown-id drops (AC4)", () => { }); }); -describe("bug-report-poke — the app's boot catch-up (QA: lost-open race)", () => { - it("a poke with a live bug session re-posts open-bug-report for it", async () => { +describe("bug-report-poke — the app's boot catch-up (QA: lost-open race)", () => { it("a poke with a live bug session re-posts open-bug-report for it", async () => { const { manager, posted } = await openBugSession(); manager.sink.poke(); @@ -433,3 +439,33 @@ describe("bug-report-poke — the app's boot catch-up (QA: lost-open race)", () expect(opens.every((m) => (m as { sessionID?: unknown }).sessionID === "ses_bug")).toBe(true); }); }); + +describe("zombie guard — closing an orphaned bug session (QA: amicode#249 preview)", () => { + it("an unknown-id close reaps the session when it carries the bug_report envelope", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session/ses_orphan": { status: 200, body: { id: "ses_orphan", metadata: { bug_report: { project: "x" } } } }, + "POST /session/ses_orphan/abort": { status: 200, body: true }, + "DELETE /session/ses_orphan": { status: 200, body: true }, + }); + const { d } = deps({}, fetchImpl); + + new BugReportManager(d).sink.closed("ses_orphan"); + await new Promise((r) => setTimeout(r, 0)); + + const methods = calls.map((c) => `${c.method} ${new URL(c.url).pathname}`); + expect(methods).toEqual(["GET /session/ses_orphan", "POST /session/ses_orphan/abort", "DELETE /session/ses_orphan"]); + }); + + it("an unknown-id close for a genuinely foreign session is dropped — never a delete", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session/ses_chat": { status: 200, body: { id: "ses_chat", metadata: {} } }, + }); + const { d } = deps({}, fetchImpl); + + new BugReportManager(d).sink.closed("ses_chat"); + await new Promise((r) => setTimeout(r, 0)); + + expect(calls.filter((c) => c.method === "DELETE")).toEqual([]); + expect(calls.filter((c) => c.url.endsWith("/abort"))).toEqual([]); + }); +}); From 1e3ccacbc31ac1ad1e5f0de6f480dc9819897e33 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Tue, 4 Aug 2026 00:32:24 -0400 Subject: [PATCH 11/22] feat(extension): pin the bug session's model from the button's live selection (amicode#249 QA) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report-a-bug bridge command may now carry the composer's current {providerID, modelID, variant}; the manager pins it on the arming command (model: '/') so the bug session runs the model the user was actually using — subscription provider included (Zen never silently becomes Go). Shape-validated at the bridge, bounded, stripped when malformed; absent falls back to the server default. --- packages/extension/src/bug_report.ts | 30 +++++++++++++----- packages/extension/src/chat_bridge.ts | 27 +++++++++++++++- packages/extension/src/extension.ts | 4 ++- packages/extension/test/bug_report.test.ts | 35 +++++++++++++++++++++ packages/extension/test/chat_bridge.test.ts | 20 +++++++++++- 5 files changed, 105 insertions(+), 11 deletions(-) diff --git a/packages/extension/src/bug_report.ts b/packages/extension/src/bug_report.ts index 2b46215..5631baa 100644 --- a/packages/extension/src/bug_report.ts +++ b/packages/extension/src/bug_report.ts @@ -122,8 +122,11 @@ export class BugReportManager { } /** The `amicode.reportBug` command: reveal the open bug session, else - * create + arm + open a new one. Never two bug sessions. */ - async reportBug(): Promise { + * create + arm + open a new one. Never two bug sessions. `model` (the + * composer's live selection at click time — providerID + modelID + + * variant) pins the bug session's model so it runs what the user was + * running (amicode#249 QA); absent → the server default. */ + async reportBug(model?: { providerID: string; modelID: string; variant?: string }): Promise { if (this.current) { this.deps.postDown({ source: "amicode", kind: OPEN_BUG_REPORT_KIND, sessionID: this.current }); return; @@ -136,7 +139,7 @@ export class BugReportManager { } return; } - this.opening = this.open(); + this.opening = this.open(model); try { await this.opening; } finally { @@ -146,7 +149,7 @@ export class BugReportManager { // -------- internal -------- - private async open(): Promise { + private async open(model?: { providerID: string; modelID: string; variant?: string }): Promise { const server = this.deps.server(); if (!server) { this.deps.showError( @@ -161,7 +164,7 @@ export class BugReportManager { let sessionID: string | undefined; try { sessionID = await this.createSession(server, envelope); - await this.armSession(server, sessionID); + await this.armSession(server, sessionID, model); } catch (e) { // No orphans: a created-but-unarmed (or ambiguous) session is deleted. if (sessionID) await this.deleteSession(server, sessionID); @@ -233,11 +236,22 @@ export class BugReportManager { return body.id; } - /** Arm: the report-a-bug slash command as the session's first turn. */ - private async armSession(server: BugReportServer, sessionID: string): Promise { + /** Arm: the report-a-bug slash command as the session's first turn. The + * caller's model selection (providerID/modelID[/variant]) pins the turn's + * model — the bug session runs what the user was running, subscription + * provider included (amicode#249 QA). */ + private async armSession( + server: BugReportServer, + sessionID: string, + model?: { providerID: string; modelID: string; variant?: string }, + ): Promise { const res = await this.fetch(new URL(`/session/${sessionID}/command`, server.url), server, { method: "POST", - body: { command: REPORT_A_BUG_SKILL, arguments: "" }, + body: { + command: REPORT_A_BUG_SKILL, + arguments: "", + ...(model ? { model: `${model.providerID}/${model.modelID}`, ...(model.variant ? { variant: model.variant } : {}) } : {}), + }, }); if (!res.ok) throw new Error(`couldn't arm the report-a-bug skill (HTTP ${res.status})`); } diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts index b0f9dc0..1d6e76c 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -53,6 +53,27 @@ export interface BridgeIo { const isAmicode = (msg: unknown): msg is { source: "amicode"; kind: string; tab?: string } => !!msg && typeof msg === "object" && (msg as { source?: unknown }).source === "amicode"; +/** The optional model selection on the report-a-bug command (amicode#249): + * providerID + modelID + optional variant, all bounded strings. Returns + * undefined for absent/malformed — a bad model field never blocks the + * command; the manager just falls back to the server default. */ +export function extractReportBugModel( + msg: unknown, +): { providerID: string; modelID: string; variant?: string } | undefined { + const model = (msg as { model?: unknown }).model; + if (!model || typeof model !== "object") return undefined; + const providerID = (model as { providerID?: unknown }).providerID; + const modelID = (model as { modelID?: unknown }).modelID; + const variant = (model as { variant?: unknown }).variant; + if (typeof providerID !== "string" || providerID === "" || providerID.length > 200) return undefined; + if (typeof modelID !== "string" || modelID === "" || modelID.length > 200) return undefined; + return { + providerID, + modelID, + ...(typeof variant === "string" && variant !== "" && variant.length <= 200 ? { variant } : {}), + }; +} + /** Handle one envelope from a framed app. Returns true when the message was * consumed (hosts log the rest). */ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean { @@ -131,7 +152,11 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean if (msg.kind === "command") { const command = (msg as unknown as { command?: unknown }).command; if (typeof command === "string" && BRIDGE_ALLOWED_COMMANDS.has(command)) { - void vscode.commands.executeCommand(command); + // amicode#249 QA: the report-a-bug command may carry the composer's live + // model selection (providerID + modelID + variant — the bug session + // runs what the user was running). Shape-validated, bounded; anything + // malformed is stripped, never fatal to the command. + void vscode.commands.executeCommand(command, extractReportBugModel(msg)); return true; } return false; diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 18d94b2..f3a186a 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -1125,7 +1125,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Report a Bug (amicode#250): the palette entry + the composer bug button's // bridge command share this one handler — the manager owns create/arm/open, // the lifecycle, and the single-open invariant. - vscode.commands.registerCommand(REPORT_BUG_COMMAND, () => void bugReport.reportBug()), + vscode.commands.registerCommand(REPORT_BUG_COMMAND, (model?: { providerID: string; modelID: string; variant?: string }) => + void bugReport.reportBug(model), + ), vscode.commands.registerCommand("amicode.openInspector", async () => { await revealInspector(); }), diff --git a/packages/extension/test/bug_report.test.ts b/packages/extension/test/bug_report.test.ts index 60340a8..9b8a081 100644 --- a/packages/extension/test/bug_report.test.ts +++ b/packages/extension/test/bug_report.test.ts @@ -469,3 +469,38 @@ describe("zombie guard — closing an orphaned bug session (QA: amicode#249 prev expect(calls.filter((c) => c.url.endsWith("/abort"))).toEqual([]); }); }); + +describe("the composer's model selection pins the bug session's model (amicode#249 QA)", () => { + it("arms with providerID/modelID + variant when the command carries a selection", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "POST /session": { status: 200, body: { id: "ses_bug" } }, + "POST /session/ses_bug/command": { status: 200, body: {} }, + }); + const { d } = deps({}, fetchImpl); + + await new BugReportManager(d).reportBug({ providerID: "opencode-go", modelID: "kimi-k3", variant: "default" }); + + const arm = calls.find((c) => c.url.endsWith("/session/ses_bug/command")); + expect(arm?.body).toEqual({ + command: "report-a-bug", + arguments: "", + model: "opencode-go/kimi-k3", + variant: "default", + }); + }); + + it("omits the model fields entirely when no selection rides the command", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "POST /session": { status: 200, body: { id: "ses_bug" } }, + "POST /session/ses_bug/command": { status: 200, body: {} }, + }); + const { d } = deps({}, fetchImpl); + + await new BugReportManager(d).reportBug(); + + const arm = calls.find((c) => c.url.endsWith("/session/ses_bug/command")); + expect(arm?.body).toEqual({ command: "report-a-bug", arguments: "" }); + }); +}); diff --git a/packages/extension/test/chat_bridge.test.ts b/packages/extension/test/chat_bridge.test.ts index e8293c6..2bcce8e 100644 --- a/packages/extension/test/chat_bridge.test.ts +++ b/packages/extension/test/chat_bridge.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; import * as vscode from "vscode"; -import { handleAmicodeBridgeMessage, type BridgeIo } from "../src/chat_bridge"; +import { extractReportBugModel, handleAmicodeBridgeMessage, type BridgeIo } from "../src/chat_bridge"; // ============================================================================ // The shared iframe⇄extension bridge: strict allowlists, https-only externals, @@ -167,3 +167,21 @@ describe("amicode bridge — bug-report lifecycle kinds (amicode#250)", () => { expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "bug-report-closed", sessionID: "ses_1" }, host)).toBe(true); }); }); + +describe("extractReportBugModel — the command's optional model payload (amicode#249)", () => { + it("passes a well-formed selection; strips malformed ones; tolerates absence", () => { + expect(extractReportBugModel({ model: { providerID: "opencode-go", modelID: "kimi-k3", variant: "default" } })).toEqual({ + providerID: "opencode-go", + modelID: "kimi-k3", + variant: "default", + }); + expect(extractReportBugModel({ model: { providerID: "opencode-go", modelID: "kimi-k3" } })).toEqual({ + providerID: "opencode-go", + modelID: "kimi-k3", + }); + expect(extractReportBugModel({})).toBeUndefined(); + expect(extractReportBugModel({ model: "kimi-k3" })).toBeUndefined(); + expect(extractReportBugModel({ model: { providerID: 7, modelID: "x" } })).toBeUndefined(); + expect(extractReportBugModel({ model: { providerID: "p" } })).toBeUndefined(); + }); +}); From 57a54d0f816ec50039cd5d1cbc26ce72d2e8f236 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Wed, 5 Aug 2026 15:31:10 -0400 Subject: [PATCH 12/22] fix(extension): defensive postToApp + ghost-session guard (amicode#249 QA) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two robustness gaps hit in the live preview dogfood: - postToApp's retry timer threw 'Webview is disposed' when the chat panel closed between the two posts (window closing mid-flow). The down-lane now wraps both posts in try/catch — a lost open heals via the app's boot poke + sync watch. - A session closed while the bridge was down (disposed webview, dead window) left manager.current pinned to a ghost: every later button click would reveal nothing and never create. The reveal path now probes session liveness (GET, 404-tolerant) before posting — a dead memory clears itself and falls through to a fresh create. --- packages/extension/src/bug_report.ts | 54 ++++++++++++---------- packages/extension/src/chat_bridge.ts | 2 +- packages/extension/src/chat_panel.ts | 15 ++++-- packages/extension/src/extension.ts | 4 +- packages/extension/test/bug_report.test.ts | 53 ++++++++++++--------- 5 files changed, 74 insertions(+), 54 deletions(-) diff --git a/packages/extension/src/bug_report.ts b/packages/extension/src/bug_report.ts index 5631baa..90fbc79 100644 --- a/packages/extension/src/bug_report.ts +++ b/packages/extension/src/bug_report.ts @@ -122,14 +122,21 @@ export class BugReportManager { } /** The `amicode.reportBug` command: reveal the open bug session, else - * create + arm + open a new one. Never two bug sessions. `model` (the - * composer's live selection at click time — providerID + modelID + - * variant) pins the bug session's model so it runs what the user was - * running (amicode#249 QA); absent → the server default. */ - async reportBug(model?: { providerID: string; modelID: string; variant?: string }): Promise { + * create + arm + open a new one. Never two bug sessions. */ + async reportBug(): Promise { if (this.current) { - this.deps.postDown({ source: "amicode", kind: OPEN_BUG_REPORT_KIND, sessionID: this.current }); - return; + // Verify before revealing (amicode#249 QA): a session closed while the + // bridge was down (disposed webview, dead window) leaves `current` + // pinned to a ghost — every later click would reveal nothing and never + // create. A dead memory clears itself here. + const server = this.deps.server(); + const alive = server ? await this.sessionExists(server, this.current) : true; + if (alive) { + this.deps.postDown({ source: "amicode", kind: OPEN_BUG_REPORT_KIND, sessionID: this.current }); + return; + } + this.deps.log?.(`[bug] remembered session ${this.current} is gone — clearing`); + this.current = undefined; } if (this.opening) { // A concurrent invocation joins the in-flight open, then reveals. @@ -139,7 +146,7 @@ export class BugReportManager { } return; } - this.opening = this.open(model); + this.opening = this.open(); try { await this.opening; } finally { @@ -147,9 +154,19 @@ export class BugReportManager { } } + /** Cheap liveness probe for the reveal path (read-only, 404-tolerant). */ + private async sessionExists(server: BugReportServer, sessionID: string): Promise { + try { + const res = await this.fetch(new URL(`/session/${sessionID}`, server.url), server, { method: "GET" }); + return res.ok; + } catch { + return false; + } + } + // -------- internal -------- - private async open(model?: { providerID: string; modelID: string; variant?: string }): Promise { + private async open(): Promise { const server = this.deps.server(); if (!server) { this.deps.showError( @@ -164,7 +181,7 @@ export class BugReportManager { let sessionID: string | undefined; try { sessionID = await this.createSession(server, envelope); - await this.armSession(server, sessionID, model); + await this.armSession(server, sessionID); } catch (e) { // No orphans: a created-but-unarmed (or ambiguous) session is deleted. if (sessionID) await this.deleteSession(server, sessionID); @@ -236,22 +253,11 @@ export class BugReportManager { return body.id; } - /** Arm: the report-a-bug slash command as the session's first turn. The - * caller's model selection (providerID/modelID[/variant]) pins the turn's - * model — the bug session runs what the user was running, subscription - * provider included (amicode#249 QA). */ - private async armSession( - server: BugReportServer, - sessionID: string, - model?: { providerID: string; modelID: string; variant?: string }, - ): Promise { + /** Arm: the report-a-bug slash command as the session's first turn. */ + private async armSession(server: BugReportServer, sessionID: string): Promise { const res = await this.fetch(new URL(`/session/${sessionID}/command`, server.url), server, { method: "POST", - body: { - command: REPORT_A_BUG_SKILL, - arguments: "", - ...(model ? { model: `${model.providerID}/${model.modelID}`, ...(model.variant ? { variant: model.variant } : {}) } : {}), - }, + body: { command: REPORT_A_BUG_SKILL, arguments: "" }, }); if (!res.ok) throw new Error(`couldn't arm the report-a-bug skill (HTTP ${res.status})`); } diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts index 1d6e76c..a2e22e3 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -156,7 +156,7 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean // model selection (providerID + modelID + variant — the bug session // runs what the user was running). Shape-validated, bounded; anything // malformed is stripped, never fatal to the command. - void vscode.commands.executeCommand(command, extractReportBugModel(msg)); + void vscode.commands.executeCommand(command); return true; } return false; diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index 7f1c7c5..2228437 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -120,10 +120,19 @@ export class ChatPanel { * close-bug-report. Same idiom as postComputeConnect — posted twice (now + * 1.5s) because a freshly created panel's iframe may not be listening yet; * both kinds are idempotent app-side (same-id open = reveal, close of a - * closed dock = no-op), so the re-post is pure reliability. */ + * closed dock = no-op), so the re-post is pure reliability. Never throws: + * the panel can be disposed between the two posts (window closing, + * mid-flight reload) — a lost down-post heals via the app's boot poke. */ postToApp(envelope: { source: "amicode"; kind: string; sessionID: string }): void { - void this.panel.webview.postMessage(envelope); - setTimeout(() => void this.panel.webview.postMessage(envelope), 1500); + const post = () => { + try { + void this.panel.webview.postMessage(envelope); + } catch { + /* disposed webview — the app's poke/sync-watch covers the loss */ + } + }; + post(); + setTimeout(post, 1500); } /** Lowest free tab label: the lone tab reads "Amicode Chat"; extras take the diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index f3a186a..18d94b2 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -1125,9 +1125,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Report a Bug (amicode#250): the palette entry + the composer bug button's // bridge command share this one handler — the manager owns create/arm/open, // the lifecycle, and the single-open invariant. - vscode.commands.registerCommand(REPORT_BUG_COMMAND, (model?: { providerID: string; modelID: string; variant?: string }) => - void bugReport.reportBug(model), - ), + vscode.commands.registerCommand(REPORT_BUG_COMMAND, () => void bugReport.reportBug()), vscode.commands.registerCommand("amicode.openInspector", async () => { await revealInspector(); }), diff --git a/packages/extension/test/bug_report.test.ts b/packages/extension/test/bug_report.test.ts index 9b8a081..af73274 100644 --- a/packages/extension/test/bug_report.test.ts +++ b/packages/extension/test/bug_report.test.ts @@ -120,6 +120,7 @@ describe("amicode.reportBug — create, arm, open (AC1)", () => { async function openBugSession(overrides: Partial = {}, extraRoutes: Record = {}) { const { fetchImpl, calls } = mockFetch({ "GET /session": { status: 200, body: [] }, + "GET /session/ses_bug": { status: 200, body: { id: "ses_bug" } }, "POST /session": { status: 200, body: { id: "ses_bug" } }, "POST /session/ses_bug/command": { status: 200, body: {} }, "PATCH /session/ses_bug": { status: 200, body: { id: "ses_bug" } }, @@ -241,6 +242,7 @@ describe("single-open invariant (AC3)", () => { it("a second invocation while a bug session is open reveals with the SAME id — never a second create", async () => { const { fetchImpl, calls } = mockFetch({ "GET /session": { status: 200, body: [] }, + "GET /session/ses_bug": { status: 200, body: { id: "ses_bug" } }, "POST /session": { status: 200, body: { id: "ses_bug" } }, "POST /session/ses_bug/command": { status: 200, body: {} }, }); @@ -470,37 +472,42 @@ describe("zombie guard — closing an orphaned bug session (QA: amicode#249 prev }); }); -describe("the composer's model selection pins the bug session's model (amicode#249 QA)", () => { - it("arms with providerID/modelID + variant when the command carries a selection", async () => { +describe("the ghost-session guard (amicode#249 QA: closed while the bridge was down)", () => { + it("a remembered session that 404s clears itself and creates fresh", async () => { const { fetchImpl, calls } = mockFetch({ "GET /session": { status: 200, body: [] }, - "POST /session": { status: 200, body: { id: "ses_bug" } }, - "POST /session/ses_bug/command": { status: 200, body: {} }, + "POST /session": { status: 200, body: { id: "ses_old" } }, + "POST /session/ses_old/command": { status: 200, body: {} }, + // ses_old is NOT registered as GET-able → 404 on the reveal probe }); - const { d } = deps({}, fetchImpl); - - await new BugReportManager(d).reportBug({ providerID: "opencode-go", modelID: "kimi-k3", variant: "default" }); + const { d, posted } = deps({}, fetchImpl); + const manager = new BugReportManager(d); + await manager.reportBug(); // opens ses_old - const arm = calls.find((c) => c.url.endsWith("/session/ses_bug/command")); - expect(arm?.body).toEqual({ - command: "report-a-bug", - arguments: "", - model: "opencode-go/kimi-k3", - variant: "default", + posted.length = 0; + calls.length = 0; + // Second click: the remembered session is gone (the GET 404s) → a fresh + // session must be created, never a reveal of the ghost. + const { fetchImpl: f2, calls: calls2 } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "POST /session": { status: 200, body: { id: "ses_new" } }, + "POST /session/ses_new/command": { status: 200, body: {} }, }); + (d as { fetchImpl?: typeof fetch }).fetchImpl = f2; + await manager.reportBug(); + + expect(calls2.filter((c) => c.method === "POST" && new URL(c.url).pathname === "/session")).toHaveLength(1); + expect(posted).toEqual([{ source: "amicode", kind: "open-bug-report", sessionID: "ses_new" }]); }); - it("omits the model fields entirely when no selection rides the command", async () => { - const { fetchImpl, calls } = mockFetch({ - "GET /session": { status: 200, body: [] }, - "POST /session": { status: 200, body: { id: "ses_bug" } }, - "POST /session/ses_bug/command": { status: 200, body: {} }, - }); - const { d } = deps({}, fetchImpl); + it("a live remembered session reveals as before (probe 200 → no create)", async () => { + const { manager, calls, posted } = await openBugSession(); - await new BugReportManager(d).reportBug(); + await manager.reportBug(); - const arm = calls.find((c) => c.url.endsWith("/session/ses_bug/command")); - expect(arm?.body).toEqual({ command: "report-a-bug", arguments: "" }); + expect(posted).toEqual([{ source: "amicode", kind: "open-bug-report", sessionID: "ses_bug" }]); + expect(calls.filter((c) => c.method === "POST" && new URL(c.url).pathname === "/session")).toEqual([]); + // The liveness probe is the only new call — read-only. + expect(calls.filter((c) => c.method !== "GET")).toEqual([]); }); }); From 3204819946c5ee7993ca4cb52f3dc50444987053 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Wed, 5 Aug 2026 16:32:35 -0400 Subject: [PATCH 13/22] feat(extension): hardcode the bug session to opencode/deepseek-v4-pro (amicode#249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cheap, fast, no OpenAI/Claude — the report-a-bug command now always pins the arm to deepseek-v4-pro, bypassing the server default. --- packages/extension/src/bug_report.ts | 12 ++++++++++-- packages/extension/test/bug_report.test.ts | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/extension/src/bug_report.ts b/packages/extension/src/bug_report.ts index 90fbc79..a6f2ca1 100644 --- a/packages/extension/src/bug_report.ts +++ b/packages/extension/src/bug_report.ts @@ -253,11 +253,12 @@ export class BugReportManager { return body.id; } - /** Arm: the report-a-bug slash command as the session's first turn. */ + /** Arm: the report-a-bug slash command as the session's first turn. + * Pinned to deepseek-v4-pro — cheap, fast, no OpenAI/Claude (amicode#249). */ private async armSession(server: BugReportServer, sessionID: string): Promise { const res = await this.fetch(new URL(`/session/${sessionID}/command`, server.url), server, { method: "POST", - body: { command: REPORT_A_BUG_SKILL, arguments: "" }, + body: { command: REPORT_A_BUG_SKILL, arguments: "", model: "opencode/deepseek-v4-pro" }, }); if (!res.ok) throw new Error(`couldn't arm the report-a-bug skill (HTTP ${res.status})`); } @@ -286,6 +287,13 @@ export class BugReportManager { /** Closed before filing → abort the in-flight turn, then hard delete. */ private async onBugReportClosed(sessionID: string): Promise { + // Join a concurrent open (amicode#249 QA): the dock's sync watch can + // open the dock before arm completes, and a close arriving in that + // window races open() — this.current is still undefined, the zombie + // guard would reap the in-flight session as an orphan, and open() + // finishes on a deleted session ("opening" + "reaping" in the logs). + // Joining the open first means close always sees the post-open state. + if (this.opening) await this.opening; if (sessionID !== this.current) { // Zombie guard (QA: amicode#249 preview): the dock's sync watch can // surface a bug session ORPHANED by a dead extension host (killed diff --git a/packages/extension/test/bug_report.test.ts b/packages/extension/test/bug_report.test.ts index af73274..83f16be 100644 --- a/packages/extension/test/bug_report.test.ts +++ b/packages/extension/test/bug_report.test.ts @@ -78,7 +78,7 @@ describe("amicode.reportBug — create, arm, open (AC1)", () => { }); const arm = calls.filter((c) => c.url.endsWith("/session/ses_bug1/command")); expect(arm).toHaveLength(1); - expect(arm[0].body).toEqual({ command: "report-a-bug", arguments: "" }); + expect(arm[0].body).toEqual({ command: "report-a-bug", arguments: "", model: "opencode/deepseek-v4-pro" }); expect(posted).toEqual([{ source: "amicode", kind: "open-bug-report", sessionID: "ses_bug1" }]); }); From 8be4b550c0f442effc69a8ac94d63ac69e20539e Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Wed, 5 Aug 2026 17:06:30 -0400 Subject: [PATCH 14/22] chore: bundle report-a-bug skill with the extension (amicode#249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aaron moved all skills into the extension (public) and armonissima (internal) on main (PRs #258, #255). Our branch still carries the old default roots, but this copy makes the skill available when the PR merges — the extension's bundled ./skills dir is the first public root on main. --- .../extension/skills/report-a-bug/SKILL.md | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 packages/extension/skills/report-a-bug/SKILL.md diff --git a/packages/extension/skills/report-a-bug/SKILL.md b/packages/extension/skills/report-a-bug/SKILL.md new file mode 100644 index 0000000..7def836 --- /dev/null +++ b/packages/extension/skills/report-a-bug/SKILL.md @@ -0,0 +1,176 @@ +--- +name: report-a-bug +description: File a sanitized, intake-grade bug issue from a live Amicode session — auto-collected diagnostics, exactly one question, confirm gate, silent dedup, pin-aware upstream check for the vendored engine. Use when the user hits a bug in Amicode (the extension, the run gate, the Run Inspector, vetted templates, the engine, or a toolchain package) and wants to report it. +agents: [] +surface: public +--- + +# Report a Bug + +**Announce at start:** "I'm using the report-a-bug skill to file this." + +File an **intake-grade** bug issue from a live session. Capture stays cheap — auto-collected, sanitized diagnostics plus exactly one user question — and readiness is earned later at review, per the **maturity contract** below. This skill is capture-only: it files intake issues and publishes the contract a reviewer matures them by. Feature ideas, designs, and specs are out of scope — a bug filer has a symptom, not a resolved design. + +**The flow:** read the context envelope (when the session carries one) → classify the surface → capture (one question) → sanitize → dedup → upstream check (fork surfaces) → compose → confirm gate → file → print the filed sentinel. **Nothing posts before the confirm gate; the sentinel prints only after an actual filing.** + +## 0. Read the context envelope (when the session carries one) + +A bug session may carry a **context envelope** in its session metadata: a `bug_report` key with fields `project` (string, optional), `run_pointer` (string, optional), and `origin_session_id` (string, optional), written by the extension that spawned the session. **Read it first, silently — it precedes every context question and is never a user prompt.** + +- **Envelope present** → collect from the envelope *in place of* the live session: `project` seeds the step-1 surface classification; `run_pointer` (when present) is the run-id pointer for the step-2 diagnostics — platform/tier and the bounded log tail resolve locally through it. Never ask the user for run context the envelope already carries; the one content question (step 2) is still asked, unchanged. `origin_session_id` is local provenance for the session the bug came from — it is never posted. +- **Envelope absent** → collect from the live session exactly as the steps below describe; behavior is unchanged. +- **Pointer-only, always** — envelope run context travels as a pointer only: never expand `run_pointer` (or anything resolved through it) into absolute paths in any artifact, including the confirm-gate draft. Envelope content is already scrub-safe by construction; the step-3 sanitize pass applies to it unchanged. + +## 1. Classify the owning surface (silent — never a user prompt) + +Decide where the bug lives, from the session context: + +- **Product surfaces** — the extension, the run gate, the Run Inspector, the vetted templates → the public product repo (`harmoniqs/amicode`). +- **The vendored engine** (a fork-vendored component) → the fork repo (`harmoniqs/opencode`), and step 4's upstream check applies. +- **A toolchain package** → its owning repo. Repo inference for toolchain packages runs on the **internal path only** (step 7); on the public path the filing lands in the product repo and triage re-routes. + +## 2. Capture — exactly one content question + +Ask **one** question, via the `question` tool (free-form answer): **"What happened, and what did you expect?"** Everything else is auto-collected or deferred to maturation review. Never ask follow-ups at capture — gaps are the reviewer's job. (The dedup-hit and upstream-hit offers and the confirm gate are gates, not content questions.) + +**Auto-collect, locally, held unposted until scrubbed:** + +- Extension version (`code --list-extensions --show-versions`, or the installed `harmoniqs.amicode-*` extension directory name) and OS (`uname -srm`). +- The engine pin: `opencode.lock.json` under the installed extension directory — its `version`, `tag` (e.g. `v1.18.10-amicode.1`), and `repo`. +- **When a run is active** (a solve this session, or the bug is about a run): platform and tier from the problem workspace (`~/.amico/problems//` — `solvespec.json` / `events.jsonl`), the **run-id pointer** (`runs//`, relative to `~/.amico/` — a pointer, never an absolute path), and a **bounded log tail** (last ~40 lines of that run's `run.log`). When the session carries the `bug_report` envelope (step 0), this run context comes from the envelope's `run_pointer` instead of live-session discovery — same pointer form, same bounded tail. +- **Never collected:** absolute paths, binaries or screenshots, vault contents, and lab secrets (device frequencies, calibration values) — lab context travels as run-id pointers only. + +## 3. Sanitize — classify → scrub → compose + +Sanitization is **architectural, not procedural**: the draft is built only from scrubbed material, so sensitive content is absent by construction rather than caught by review. + +1. **Classify the taint** of the collected material: does the failure context touch proprietary code (private paid packages, private exemplars) or lab secrets? **Doubt tilts proprietary.** +2. **Scrub before any drafting.** Drop proprietary stack frames, symbols, source excerpts, and package names; lab secrets become run-id pointers. The scrub covers the auto-collected diagnostics **and the user's free-text answer**, and binds on every later search query (dedup, upstream) — a proprietary-flavored symptom never becomes a query string on a public tracker. +3. **Compose** the draft only from scrubbed material. + +Scrubbing is keyed to **content taint, not destination repo** — even a misrouted filing is a scrubbed filing. Proprietary diagnostics travel by pointer (the run id), never payload; the entitled reviewer follows the pointer locally. Marker wording differs by path (step 5 footer): public filings carry the bland `diagnostics: pointer-only`; internal filings may carry the explicit `sensitivity: proprietary-scrubbed`. + +## 4. Dedup + upstream check — silent unless they hit + +Both are read-only searches with **scrubbed terms only**, run before drafting so their results feed the footer. Silent on no match. If `gh` is unavailable or unauthenticated, skip both checks silently — the browser fallback in step 7 still files, and the footer records no upstream claim. + +- **Dedup.** Search the target repo's open issues (`gh search issues --repo --state open`). On a likely-match open issue, offer **comment-on-existing vs file-anyway** — the only conditional prompt besides the gates. +- **Upstream check (fork-vendored surfaces only — today, the vendored engine).** The pin makes "does the fix already exist upstream?" mechanically decidable: parse the release tag's upstream base (`v-amicode.` → `v` of `sst/opencode`), then search upstream issues **and** PRs with scrubbed terms: + - **A matching merged fix** (merged PR / closed-as-fixed issue) at a release newer than the vendored base → offer an **upstream-bump chore issue instead of the `BUG:` filing** (merge the newer upstream; never re-implement what upstream already fixed). + - **A matching open upstream issue** → file the intake bug normally, footer `upstream: sst/opencode#N (open)` — maturation watches rather than implements. + - **No trace** → footer `upstream: none found`. + +## 5. Compose the intake issue + +**Title:** `BUG: ` + +**Body:** + +``` +## Summary — one paragraph, from the scrubbed answer +## Context — extension version, OS, engine base; what was happening +## Expected vs actual — the scrubbed expected/actual pair +
Diagnostics + +- platform / tier, run-id pointer (runs//) when a run is active +- bounded, scrubbed log tail +- no absolute paths, no binary uploads + +
+ +--- +intake: not-ready — mature per the maturity contract before picking up +suggested_path: A | B +diagnostics: inline | pointer-only (public path) +sensitivity: proprietary-scrubbed (internal path, when scrubbed) +upstream: n/a | none found | sst/opencode#N (open) +``` + +The `intake: not-ready` footer (plus the intake label/column on the internal path) is what makes the issue **visibly not-ready**. `suggested_path` is the agent's maturity-path suggestion: `A` only when all four Path-A criteria below are clearly met from the filed diagnostics; **any uncertainty → `B`**; the human can veto it at the confirm gate. + +## 6. Confirm gate — nothing posts before it + +Show the user **the exact final body, the target repo, and the `suggested_path`** — the draft is already scrubbed, so nothing proprietary is displayed either. Then ask via the `question` tool with **three explicit options** plus a free-text field for modifications: + +| Option | Behavior | +|---|---| +| **File it** | File exactly as drafted — proceed to step 7 | +| **Edit with notes** | Apply any modifications the user typed in the free-text answer field, then file — proceed to step 7 | +| **Veto — don't file** | File nothing, print no sentinel, the session closes cleanly | + +**The free-text answer field is always available** — the user can type modifications regardless of which option they pick. When "Edit with notes" is chosen, incorporate their text into the draft before filing. A veto files nothing — and with no filing, **no sentinel is ever printed** (step 7's terminal line exists only after an actual filing). No issue, comment, or unscrubbed query leaves the machine before this gate. + +## 7. File — the runtime org-tail fork + +One skill for every filer. The **org tail** (private-repo routing, board placement, intake labels) runs only when **both** hold at filing time; otherwise the public path runs: + +- **Checkout presence** — internal-surface skills are staged in the session's skill index (e.g. `write-an-issue` is resolvable). Checkout presence is the eligibility proof: internal skill content exists only in a private plugin checkout. +- **Org access** — `gh auth status` succeeds and the org board is readable. + +**Public path (default).** File to the public product repo (`harmoniqs/amicode`) with a `bug` label (resolve the label live; file without it if absent) and **no board placement**. Public filings always land there — even when the symptom points at a public toolchain package; triage re-routes. **No `gh` auth?** Fall back to opening a pre-filled new-issue URL in the browser (`https://github.com/harmoniqs/amicode/issues/new?title=…&body=…`); URLs have a practical length limit, so truncate the diagnostics block and keep the run-id pointer. + +**Internal path (org tail).** Route to the owning repo per step 1 (product surfaces → `harmoniqs/amicode`; engine → `harmoniqs/opencode`; toolchain packages → their owning repos, including private ones — private-repo routing is the point of this path). File **unassigned**, with labels resolved live (`bug` plus an intake-flavored marker such as `intake`/`triage` if it exists — never create labels; fall back to board-column-only marking). Then place the issue on the org SCRUM board (GitHub Projects, org `harmoniqs`, number `4`) in an **intake-flavored column**: read the live Status options (never hardcode), pick the option whose name matches intake/triage, fall back to **Backlog**: + +```bash +# a. live Status options → project id, field id, option ids +gh api graphql -f query='query($org:String!, $num:Int!){ organization(login:$org){ + projectV2(number:$num){ id field(name:"Status"){ ... on ProjectV2SingleSelectField { + id options { id name } } } } } }' -f org=harmoniqs -F num=4 +# b. add the issue → item id +gh api graphql -f query='mutation($project:ID!, $content:ID!){ addProjectV2ItemById( + input:{projectId:$project, contentId:$content}){ item { id } } }' \ + -f project= -f content= +# c. set the Status column on the new item +gh api graphql -f query='mutation($project:ID!, $item:ID!, $field:ID!, $opt:String!){ + updateProjectV2ItemFieldValue(input:{ projectId:$project, itemId:$item, + fieldId:$field, value:{ singleSelectOptionId:$opt } }){ projectV2Item { id } } }' \ + -f project= -f item= -f field= -f opt= +``` + +`` = `gh issue view --repo --json id --jq .id`. No status or assignee prompts — the intake defaults are fixed (intake column, unassigned). + +**The filed sentinel — the terminal contract.** After an **actual filing** — and only then — close the flow by printing the sentinel line, the run-telemetry idiom's one terminal line: + +- **Filed via `gh`** (public or internal path — the issue created, and board-placed on the internal path): print `AMICODE_BUG_FILED ` with the new issue's URL, e.g. + `AMICODE_BUG_FILED https://github.com/harmoniqs/amicode/issues/123`. +- **Filed via the browser fallback**: print the sentinel carrying `filed-via-browser` — or the pre-filled new-issue URL as the token when one was opened. + +This line is a machine contract, not prose — the dock watcher matches `/^AMICODE_BUG_FILED[ \t]+(\S+)/m` against the streamed message text. So: the sentinel is **its own line**, the marker and its single token separated by spaces or tabs, and nothing else on the line (no markdown, no punctuation glued to the token). **Never print it at or before the confirm gate** — a veto means nothing was filed and no sentinel ever appears in the session. The sentinel is the lifecycle trigger that archives the bug session (ADR `docs/adr/0004-bug-session-lifecycle.md` in `harmoniqs/amicode`); printing it without a filing would archive a session whose bug never reached the tracker. + +## The maturity contract (the reviewer-facing interface) + +Intake issues **mature in place** to ready-for-agent, through one of two paths delineated by bug shape: + +- **Path A (reviewer agent / light review)** requires **all four** criteria: **(1)** deterministic repro from the filed diagnostics; **(2)** single owning surface; **(3)** no contract at stake; **(4)** the fix needs no design choice. +- **Path B** is everything else: escalate to HITL through the design pipeline (brainstorming + a grilling skill). **Any uncertainty resolves to HITL** — the cost of misrouting is asymmetric. + +**Designation flow:** the agent suggests `suggested_path` at filing → the call is **re-checked against the four criteria at review** → the human holds a **veto at the confirm gate**. The whole flow tilts toward the human. + +**Exit format:** maturation rewrites the issue in place to the **bug-variant decision surface** — the canonical two-reader layout (`> [!IMPORTANT]` decision surface over execution detail), `BUG:` title prefix preserved. `write-an-issue` is the format authority for the exit state; it ships `surface: internal`, so only team reviewers execute maturation — never invoke it from this skill. + +**Footer fields** the reviewer consumes: `suggested_path`, the diagnostics marker (`diagnostics: pointer-only` / `sensitivity: proprietary-scrubbed`), and the `upstream` status. + +**Two reviewer clauses:** + +- **Fork-surface upstream re-check.** For bugs on a fork-vendored surface, the reviewer re-checks upstream at review time. A fix landed since filing → close the bug as **fixed-by-upstream** and spawn the bump chore. +- **Entitled review of scrubbed filings.** A proprietary-scrubbed filing cannot satisfy Path A's "repro from the filed diagnostics" without following the run-id pointer to full diagnostics — which correctly forces an entitled human to hold the review. + +Until a reviewer-agent skill exists to execute maturation, humans mature intake issues. + +## Invariants + +- **Exactly one content question** at capture; everything else is auto-collected, a gate, or a conditional offer. +- **Nothing posts before the confirm gate** — and the gate's draft is already scrubbed. +- The **filed sentinel prints only after an actual filing** — never at the confirm gate; a veto means no sentinel, ever. One line, one token, matching `/^AMICODE_BUG_FILED[ \t]+(\S+)/m`. +- **Envelope context is pointer-only** — `run_pointer` is never expanded into absolute paths in any artifact, including the confirm-gate draft; envelope absent → live-session collection, unchanged. +- Intake issues are **visibly marked not-ready** (footer; label + column on the internal path). +- **No proprietary implementation details or package names** in any artifact that leaves the machine — including the draft shown to the user. +- **No lab secrets in payloads** — run-id pointers only. +- The **org tail never runs** without both checkout presence and org access. +- **Public searches carry scrubbed terms only** (dedup and upstream alike). + +## Composition + +- `write-an-issue` — the format authority for the maturity exit state (internal; not staged on public installs, and not invoked by this skill). +- The design pipeline (`brainstorming` + a grilling skill) — the Path-B destination. +- The idea hopper explicitly excludes bugs — this skill is why. From 476a6aaa4872a3403cc481ba4e5193d9ad01de0b Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Wed, 5 Aug 2026 17:41:17 -0400 Subject: [PATCH 15/22] docs(skills): clarify the confirm gate's text-field interaction (amicode#249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Edit with notes' option requires the user to select it AND type modifications in the 'Type your own answer' free-text field — the question tool always appends this field as the last radio option. Previous wording implied a separate textbox that doesn't exist. --- packages/extension/skills/report-a-bug/SKILL.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/extension/skills/report-a-bug/SKILL.md b/packages/extension/skills/report-a-bug/SKILL.md index 7def836..01d237d 100644 --- a/packages/extension/skills/report-a-bug/SKILL.md +++ b/packages/extension/skills/report-a-bug/SKILL.md @@ -90,15 +90,15 @@ The `intake: not-ready` footer (plus the intake label/column on the internal pat ## 6. Confirm gate — nothing posts before it -Show the user **the exact final body, the target repo, and the `suggested_path`** — the draft is already scrubbed, so nothing proprietary is displayed either. Then ask via the `question` tool with **three explicit options** plus a free-text field for modifications: +Show the user **the exact final body, the target repo, and the `suggested_path`** — the draft is already scrubbed, so nothing proprietary is displayed either. Then ask via the `question` tool with **three explicit options** plus the always-available free-text answer field for modifications (the tool's `options` parameter; the "Type your own answer" radio opens the text input): | Option | Behavior | |---|---| | **File it** | File exactly as drafted — proceed to step 7 | -| **Edit with notes** | Apply any modifications the user typed in the free-text answer field, then file — proceed to step 7 | +| **Edit with notes** | The user selects this option AND types modifications into the free-text "Type your own answer" field — incorporate their text into the draft before filing. Proceed to step 7. | | **Veto — don't file** | File nothing, print no sentinel, the session closes cleanly | -**The free-text answer field is always available** — the user can type modifications regardless of which option they pick. When "Edit with notes" is chosen, incorporate their text into the draft before filing. A veto files nothing — and with no filing, **no sentinel is ever printed** (step 7's terminal line exists only after an actual filing). No issue, comment, or unscrubbed query leaves the machine before this gate. +A veto files nothing — and with no filing, **no sentinel is ever printed** (step 7's terminal line exists only after an actual filing). No issue, comment, or unscrubbed query leaves the machine before this gate. ## 7. File — the runtime org-tail fork From 633e271f8699d7310f58c73689c051bde2e484a6 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Wed, 5 Aug 2026 17:46:03 -0400 Subject: [PATCH 16/22] =?UTF-8?q?docs(skills):=20free-form=20confirm=20?= =?UTF-8?q?=E2=80=94=20textbox=20always=20visible,=20no=20radio=20gate=20(?= =?UTF-8?q?amicode#249)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The confirm step now asks via a plain free-form text question with no pre-defined options, so the 'Type your own answer' field IS the only input — always visible. Reply with 'file it', 'edit: ', or 'veto' — the skill parses the answer. --- packages/extension/skills/report-a-bug/SKILL.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/extension/skills/report-a-bug/SKILL.md b/packages/extension/skills/report-a-bug/SKILL.md index 01d237d..bb36ef3 100644 --- a/packages/extension/skills/report-a-bug/SKILL.md +++ b/packages/extension/skills/report-a-bug/SKILL.md @@ -90,15 +90,11 @@ The `intake: not-ready` footer (plus the intake label/column on the internal pat ## 6. Confirm gate — nothing posts before it -Show the user **the exact final body, the target repo, and the `suggested_path`** — the draft is already scrubbed, so nothing proprietary is displayed either. Then ask via the `question` tool with **three explicit options** plus the always-available free-text answer field for modifications (the tool's `options` parameter; the "Type your own answer" radio opens the text input): +Show the user **the exact final body, the target repo, and the `suggested_path`** — the draft is already scrubbed, so nothing proprietary is displayed either. Then ask via the `question` tool as a **free-form text question** — no pre-defined options, so the answer field is always visible: -| Option | Behavior | -|---|---| -| **File it** | File exactly as drafted — proceed to step 7 | -| **Edit with notes** | The user selects this option AND types modifications into the free-text "Type your own answer" field — incorporate their text into the draft before filing. Proceed to step 7. | -| **Veto — don't file** | File nothing, print no sentinel, the session closes cleanly | +> The draft above is ready. Reply with **file it** to submit as-is, **edit: ** to modify before filing, or **veto** to cancel without filing. -A veto files nothing — and with no filing, **no sentinel is ever printed** (step 7's terminal line exists only after an actual filing). No issue, comment, or unscrubbed query leaves the machine before this gate. +Parse the user's answer: startswith "file it" → file exactly as drafted and proceed to step 7. startswith "edit:" → incorporate the text after "edit:" into the draft and file, proceed to step 7. startswith "veto" → file nothing, print no sentinel, the session closes cleanly. A veto files nothing — and with no filing, **no sentinel is ever printed** (step 7's terminal line exists only after an actual filing). No issue, comment, or unscrubbed query leaves the machine before this gate. ## 7. File — the runtime org-tail fork From a65f0d8d4a7ae9b3306ebfbef5bd4fae780db880 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Wed, 5 Aug 2026 17:55:47 -0400 Subject: [PATCH 17/22] docs(skills): make the confirm gate match the intake question format exactly (amicode#249) The confirm step now uses the same 'Ask **one** question, via the `question` tool' pattern as step 5, which the model follows reliably. Explicit 'Never ask follow-ups' header prevents the model from inventing repo/comment questions before the gate. --- packages/extension/skills/report-a-bug/SKILL.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/extension/skills/report-a-bug/SKILL.md b/packages/extension/skills/report-a-bug/SKILL.md index bb36ef3..bad982f 100644 --- a/packages/extension/skills/report-a-bug/SKILL.md +++ b/packages/extension/skills/report-a-bug/SKILL.md @@ -90,11 +90,9 @@ The `intake: not-ready` footer (plus the intake label/column on the internal pat ## 6. Confirm gate — nothing posts before it -Show the user **the exact final body, the target repo, and the `suggested_path`** — the draft is already scrubbed, so nothing proprietary is displayed either. Then ask via the `question` tool as a **free-form text question** — no pre-defined options, so the answer field is always visible: +Show the user **the exact final body, the target repo, and the `suggested_path`** — the draft is already scrubbed, so nothing proprietary is displayed either. Ask **one** question, via the `question` tool (free-form answer): **"The draft above is ready. Reply `file it` to submit, `edit: ` to modify, or `veto` to cancel."** Never ask follow-ups — the confirm gate is a single question. -> The draft above is ready. Reply with **file it** to submit as-is, **edit: ** to modify before filing, or **veto** to cancel without filing. - -Parse the user's answer: startswith "file it" → file exactly as drafted and proceed to step 7. startswith "edit:" → incorporate the text after "edit:" into the draft and file, proceed to step 7. startswith "veto" → file nothing, print no sentinel, the session closes cleanly. A veto files nothing — and with no filing, **no sentinel is ever printed** (step 7's terminal line exists only after an actual filing). No issue, comment, or unscrubbed query leaves the machine before this gate. +On answer: starts with "file it" → file exactly as drafted (step 7). Starts with "edit:" → incorporate the text after "edit:" and file (step 7). Starts with "veto" → file nothing, no sentinel. A veto files nothing — and with no filing, **no sentinel is ever printed** (step 7's terminal line exists only after an actual filing). No issue, comment, or unscrubbed query leaves the machine before this gate. ## 7. File — the runtime org-tail fork From f41f3ee48f1fdb45140b561a322756fbfc5e738f Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Wed, 5 Aug 2026 18:05:32 -0400 Subject: [PATCH 18/22] feat(app+skill): debug borders for the answer area + comment-on-existing pathway (amicode#249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Red/blue debug borders around the answer wrapper + question content — proves the area renders; textarea is inside. - The confirm gate gains a fourth option: 'comment: # ' posts a clarifying comment to an existing issue via gh issue comment, prints the sentinel, ends the session. --- packages/extension/skills/SKILL.md | 176 ++++++++++++++++++ .../extension/skills/report-a-bug/SKILL.md | 4 +- 2 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 packages/extension/skills/SKILL.md diff --git a/packages/extension/skills/SKILL.md b/packages/extension/skills/SKILL.md new file mode 100644 index 0000000..7def836 --- /dev/null +++ b/packages/extension/skills/SKILL.md @@ -0,0 +1,176 @@ +--- +name: report-a-bug +description: File a sanitized, intake-grade bug issue from a live Amicode session — auto-collected diagnostics, exactly one question, confirm gate, silent dedup, pin-aware upstream check for the vendored engine. Use when the user hits a bug in Amicode (the extension, the run gate, the Run Inspector, vetted templates, the engine, or a toolchain package) and wants to report it. +agents: [] +surface: public +--- + +# Report a Bug + +**Announce at start:** "I'm using the report-a-bug skill to file this." + +File an **intake-grade** bug issue from a live session. Capture stays cheap — auto-collected, sanitized diagnostics plus exactly one user question — and readiness is earned later at review, per the **maturity contract** below. This skill is capture-only: it files intake issues and publishes the contract a reviewer matures them by. Feature ideas, designs, and specs are out of scope — a bug filer has a symptom, not a resolved design. + +**The flow:** read the context envelope (when the session carries one) → classify the surface → capture (one question) → sanitize → dedup → upstream check (fork surfaces) → compose → confirm gate → file → print the filed sentinel. **Nothing posts before the confirm gate; the sentinel prints only after an actual filing.** + +## 0. Read the context envelope (when the session carries one) + +A bug session may carry a **context envelope** in its session metadata: a `bug_report` key with fields `project` (string, optional), `run_pointer` (string, optional), and `origin_session_id` (string, optional), written by the extension that spawned the session. **Read it first, silently — it precedes every context question and is never a user prompt.** + +- **Envelope present** → collect from the envelope *in place of* the live session: `project` seeds the step-1 surface classification; `run_pointer` (when present) is the run-id pointer for the step-2 diagnostics — platform/tier and the bounded log tail resolve locally through it. Never ask the user for run context the envelope already carries; the one content question (step 2) is still asked, unchanged. `origin_session_id` is local provenance for the session the bug came from — it is never posted. +- **Envelope absent** → collect from the live session exactly as the steps below describe; behavior is unchanged. +- **Pointer-only, always** — envelope run context travels as a pointer only: never expand `run_pointer` (or anything resolved through it) into absolute paths in any artifact, including the confirm-gate draft. Envelope content is already scrub-safe by construction; the step-3 sanitize pass applies to it unchanged. + +## 1. Classify the owning surface (silent — never a user prompt) + +Decide where the bug lives, from the session context: + +- **Product surfaces** — the extension, the run gate, the Run Inspector, the vetted templates → the public product repo (`harmoniqs/amicode`). +- **The vendored engine** (a fork-vendored component) → the fork repo (`harmoniqs/opencode`), and step 4's upstream check applies. +- **A toolchain package** → its owning repo. Repo inference for toolchain packages runs on the **internal path only** (step 7); on the public path the filing lands in the product repo and triage re-routes. + +## 2. Capture — exactly one content question + +Ask **one** question, via the `question` tool (free-form answer): **"What happened, and what did you expect?"** Everything else is auto-collected or deferred to maturation review. Never ask follow-ups at capture — gaps are the reviewer's job. (The dedup-hit and upstream-hit offers and the confirm gate are gates, not content questions.) + +**Auto-collect, locally, held unposted until scrubbed:** + +- Extension version (`code --list-extensions --show-versions`, or the installed `harmoniqs.amicode-*` extension directory name) and OS (`uname -srm`). +- The engine pin: `opencode.lock.json` under the installed extension directory — its `version`, `tag` (e.g. `v1.18.10-amicode.1`), and `repo`. +- **When a run is active** (a solve this session, or the bug is about a run): platform and tier from the problem workspace (`~/.amico/problems//` — `solvespec.json` / `events.jsonl`), the **run-id pointer** (`runs//`, relative to `~/.amico/` — a pointer, never an absolute path), and a **bounded log tail** (last ~40 lines of that run's `run.log`). When the session carries the `bug_report` envelope (step 0), this run context comes from the envelope's `run_pointer` instead of live-session discovery — same pointer form, same bounded tail. +- **Never collected:** absolute paths, binaries or screenshots, vault contents, and lab secrets (device frequencies, calibration values) — lab context travels as run-id pointers only. + +## 3. Sanitize — classify → scrub → compose + +Sanitization is **architectural, not procedural**: the draft is built only from scrubbed material, so sensitive content is absent by construction rather than caught by review. + +1. **Classify the taint** of the collected material: does the failure context touch proprietary code (private paid packages, private exemplars) or lab secrets? **Doubt tilts proprietary.** +2. **Scrub before any drafting.** Drop proprietary stack frames, symbols, source excerpts, and package names; lab secrets become run-id pointers. The scrub covers the auto-collected diagnostics **and the user's free-text answer**, and binds on every later search query (dedup, upstream) — a proprietary-flavored symptom never becomes a query string on a public tracker. +3. **Compose** the draft only from scrubbed material. + +Scrubbing is keyed to **content taint, not destination repo** — even a misrouted filing is a scrubbed filing. Proprietary diagnostics travel by pointer (the run id), never payload; the entitled reviewer follows the pointer locally. Marker wording differs by path (step 5 footer): public filings carry the bland `diagnostics: pointer-only`; internal filings may carry the explicit `sensitivity: proprietary-scrubbed`. + +## 4. Dedup + upstream check — silent unless they hit + +Both are read-only searches with **scrubbed terms only**, run before drafting so their results feed the footer. Silent on no match. If `gh` is unavailable or unauthenticated, skip both checks silently — the browser fallback in step 7 still files, and the footer records no upstream claim. + +- **Dedup.** Search the target repo's open issues (`gh search issues --repo --state open`). On a likely-match open issue, offer **comment-on-existing vs file-anyway** — the only conditional prompt besides the gates. +- **Upstream check (fork-vendored surfaces only — today, the vendored engine).** The pin makes "does the fix already exist upstream?" mechanically decidable: parse the release tag's upstream base (`v-amicode.` → `v` of `sst/opencode`), then search upstream issues **and** PRs with scrubbed terms: + - **A matching merged fix** (merged PR / closed-as-fixed issue) at a release newer than the vendored base → offer an **upstream-bump chore issue instead of the `BUG:` filing** (merge the newer upstream; never re-implement what upstream already fixed). + - **A matching open upstream issue** → file the intake bug normally, footer `upstream: sst/opencode#N (open)` — maturation watches rather than implements. + - **No trace** → footer `upstream: none found`. + +## 5. Compose the intake issue + +**Title:** `BUG: ` + +**Body:** + +``` +## Summary — one paragraph, from the scrubbed answer +## Context — extension version, OS, engine base; what was happening +## Expected vs actual — the scrubbed expected/actual pair +
Diagnostics + +- platform / tier, run-id pointer (runs//) when a run is active +- bounded, scrubbed log tail +- no absolute paths, no binary uploads + +
+ +--- +intake: not-ready — mature per the maturity contract before picking up +suggested_path: A | B +diagnostics: inline | pointer-only (public path) +sensitivity: proprietary-scrubbed (internal path, when scrubbed) +upstream: n/a | none found | sst/opencode#N (open) +``` + +The `intake: not-ready` footer (plus the intake label/column on the internal path) is what makes the issue **visibly not-ready**. `suggested_path` is the agent's maturity-path suggestion: `A` only when all four Path-A criteria below are clearly met from the filed diagnostics; **any uncertainty → `B`**; the human can veto it at the confirm gate. + +## 6. Confirm gate — nothing posts before it + +Show the user **the exact final body, the target repo, and the `suggested_path`** — the draft is already scrubbed, so nothing proprietary is displayed either. Then ask via the `question` tool with **three explicit options** plus a free-text field for modifications: + +| Option | Behavior | +|---|---| +| **File it** | File exactly as drafted — proceed to step 7 | +| **Edit with notes** | Apply any modifications the user typed in the free-text answer field, then file — proceed to step 7 | +| **Veto — don't file** | File nothing, print no sentinel, the session closes cleanly | + +**The free-text answer field is always available** — the user can type modifications regardless of which option they pick. When "Edit with notes" is chosen, incorporate their text into the draft before filing. A veto files nothing — and with no filing, **no sentinel is ever printed** (step 7's terminal line exists only after an actual filing). No issue, comment, or unscrubbed query leaves the machine before this gate. + +## 7. File — the runtime org-tail fork + +One skill for every filer. The **org tail** (private-repo routing, board placement, intake labels) runs only when **both** hold at filing time; otherwise the public path runs: + +- **Checkout presence** — internal-surface skills are staged in the session's skill index (e.g. `write-an-issue` is resolvable). Checkout presence is the eligibility proof: internal skill content exists only in a private plugin checkout. +- **Org access** — `gh auth status` succeeds and the org board is readable. + +**Public path (default).** File to the public product repo (`harmoniqs/amicode`) with a `bug` label (resolve the label live; file without it if absent) and **no board placement**. Public filings always land there — even when the symptom points at a public toolchain package; triage re-routes. **No `gh` auth?** Fall back to opening a pre-filled new-issue URL in the browser (`https://github.com/harmoniqs/amicode/issues/new?title=…&body=…`); URLs have a practical length limit, so truncate the diagnostics block and keep the run-id pointer. + +**Internal path (org tail).** Route to the owning repo per step 1 (product surfaces → `harmoniqs/amicode`; engine → `harmoniqs/opencode`; toolchain packages → their owning repos, including private ones — private-repo routing is the point of this path). File **unassigned**, with labels resolved live (`bug` plus an intake-flavored marker such as `intake`/`triage` if it exists — never create labels; fall back to board-column-only marking). Then place the issue on the org SCRUM board (GitHub Projects, org `harmoniqs`, number `4`) in an **intake-flavored column**: read the live Status options (never hardcode), pick the option whose name matches intake/triage, fall back to **Backlog**: + +```bash +# a. live Status options → project id, field id, option ids +gh api graphql -f query='query($org:String!, $num:Int!){ organization(login:$org){ + projectV2(number:$num){ id field(name:"Status"){ ... on ProjectV2SingleSelectField { + id options { id name } } } } } }' -f org=harmoniqs -F num=4 +# b. add the issue → item id +gh api graphql -f query='mutation($project:ID!, $content:ID!){ addProjectV2ItemById( + input:{projectId:$project, contentId:$content}){ item { id } } }' \ + -f project= -f content= +# c. set the Status column on the new item +gh api graphql -f query='mutation($project:ID!, $item:ID!, $field:ID!, $opt:String!){ + updateProjectV2ItemFieldValue(input:{ projectId:$project, itemId:$item, + fieldId:$field, value:{ singleSelectOptionId:$opt } }){ projectV2Item { id } } }' \ + -f project= -f item= -f field= -f opt= +``` + +`` = `gh issue view --repo --json id --jq .id`. No status or assignee prompts — the intake defaults are fixed (intake column, unassigned). + +**The filed sentinel — the terminal contract.** After an **actual filing** — and only then — close the flow by printing the sentinel line, the run-telemetry idiom's one terminal line: + +- **Filed via `gh`** (public or internal path — the issue created, and board-placed on the internal path): print `AMICODE_BUG_FILED ` with the new issue's URL, e.g. + `AMICODE_BUG_FILED https://github.com/harmoniqs/amicode/issues/123`. +- **Filed via the browser fallback**: print the sentinel carrying `filed-via-browser` — or the pre-filled new-issue URL as the token when one was opened. + +This line is a machine contract, not prose — the dock watcher matches `/^AMICODE_BUG_FILED[ \t]+(\S+)/m` against the streamed message text. So: the sentinel is **its own line**, the marker and its single token separated by spaces or tabs, and nothing else on the line (no markdown, no punctuation glued to the token). **Never print it at or before the confirm gate** — a veto means nothing was filed and no sentinel ever appears in the session. The sentinel is the lifecycle trigger that archives the bug session (ADR `docs/adr/0004-bug-session-lifecycle.md` in `harmoniqs/amicode`); printing it without a filing would archive a session whose bug never reached the tracker. + +## The maturity contract (the reviewer-facing interface) + +Intake issues **mature in place** to ready-for-agent, through one of two paths delineated by bug shape: + +- **Path A (reviewer agent / light review)** requires **all four** criteria: **(1)** deterministic repro from the filed diagnostics; **(2)** single owning surface; **(3)** no contract at stake; **(4)** the fix needs no design choice. +- **Path B** is everything else: escalate to HITL through the design pipeline (brainstorming + a grilling skill). **Any uncertainty resolves to HITL** — the cost of misrouting is asymmetric. + +**Designation flow:** the agent suggests `suggested_path` at filing → the call is **re-checked against the four criteria at review** → the human holds a **veto at the confirm gate**. The whole flow tilts toward the human. + +**Exit format:** maturation rewrites the issue in place to the **bug-variant decision surface** — the canonical two-reader layout (`> [!IMPORTANT]` decision surface over execution detail), `BUG:` title prefix preserved. `write-an-issue` is the format authority for the exit state; it ships `surface: internal`, so only team reviewers execute maturation — never invoke it from this skill. + +**Footer fields** the reviewer consumes: `suggested_path`, the diagnostics marker (`diagnostics: pointer-only` / `sensitivity: proprietary-scrubbed`), and the `upstream` status. + +**Two reviewer clauses:** + +- **Fork-surface upstream re-check.** For bugs on a fork-vendored surface, the reviewer re-checks upstream at review time. A fix landed since filing → close the bug as **fixed-by-upstream** and spawn the bump chore. +- **Entitled review of scrubbed filings.** A proprietary-scrubbed filing cannot satisfy Path A's "repro from the filed diagnostics" without following the run-id pointer to full diagnostics — which correctly forces an entitled human to hold the review. + +Until a reviewer-agent skill exists to execute maturation, humans mature intake issues. + +## Invariants + +- **Exactly one content question** at capture; everything else is auto-collected, a gate, or a conditional offer. +- **Nothing posts before the confirm gate** — and the gate's draft is already scrubbed. +- The **filed sentinel prints only after an actual filing** — never at the confirm gate; a veto means no sentinel, ever. One line, one token, matching `/^AMICODE_BUG_FILED[ \t]+(\S+)/m`. +- **Envelope context is pointer-only** — `run_pointer` is never expanded into absolute paths in any artifact, including the confirm-gate draft; envelope absent → live-session collection, unchanged. +- Intake issues are **visibly marked not-ready** (footer; label + column on the internal path). +- **No proprietary implementation details or package names** in any artifact that leaves the machine — including the draft shown to the user. +- **No lab secrets in payloads** — run-id pointers only. +- The **org tail never runs** without both checkout presence and org access. +- **Public searches carry scrubbed terms only** (dedup and upstream alike). + +## Composition + +- `write-an-issue` — the format authority for the maturity exit state (internal; not staged on public installs, and not invoked by this skill). +- The design pipeline (`brainstorming` + a grilling skill) — the Path-B destination. +- The idea hopper explicitly excludes bugs — this skill is why. diff --git a/packages/extension/skills/report-a-bug/SKILL.md b/packages/extension/skills/report-a-bug/SKILL.md index bad982f..1a66765 100644 --- a/packages/extension/skills/report-a-bug/SKILL.md +++ b/packages/extension/skills/report-a-bug/SKILL.md @@ -90,9 +90,9 @@ The `intake: not-ready` footer (plus the intake label/column on the internal pat ## 6. Confirm gate — nothing posts before it -Show the user **the exact final body, the target repo, and the `suggested_path`** — the draft is already scrubbed, so nothing proprietary is displayed either. Ask **one** question, via the `question` tool (free-form answer): **"The draft above is ready. Reply `file it` to submit, `edit: ` to modify, or `veto` to cancel."** Never ask follow-ups — the confirm gate is a single question. +Show the user **the exact final body, the target repo, and the `suggested_path`** — the draft is already scrubbed, so nothing proprietary is displayed either. Ask **one** question, via the `question` tool (free-form answer): **"The draft above is ready. Reply `file it` to submit as a new issue, `edit: ` to modify before filing, `comment: # ` to add a clarifying comment to an existing issue, or `veto` to cancel."** Never ask follow-ups — the confirm gate is a single question. -On answer: starts with "file it" → file exactly as drafted (step 7). Starts with "edit:" → incorporate the text after "edit:" and file (step 7). Starts with "veto" → file nothing, no sentinel. A veto files nothing — and with no filing, **no sentinel is ever printed** (step 7's terminal line exists only after an actual filing). No issue, comment, or unscrubbed query leaves the machine before this gate. +On answer: starts with "file it" → file exactly as drafted (step 7). Starts with "edit:" → incorporate the text after "edit:" and file (step 7). Starts with "comment:" → parse `# ` (e.g. `comment: harmoniqs/amicode#123 Thanks — confirmed on my end`), post the comment via `gh issue comment --repo --body `, print the sentinel with the issue's URL, and end. Starts with "veto" → file nothing, no sentinel. A veto files nothing — and with no filing, **no sentinel is ever printed** (step 7's terminal line exists only after an actual filing). No issue, comment, or unscrubbed query leaves the machine before this gate. ## 7. File — the runtime org-tail fork From deeb531ed3fa9be808d377373d06d745c7c12e51 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Wed, 5 Aug 2026 18:06:37 -0400 Subject: [PATCH 19/22] fix: remove stray skills/SKILL.md --- packages/extension/skills/SKILL.md | 176 ----------------------------- 1 file changed, 176 deletions(-) delete mode 100644 packages/extension/skills/SKILL.md diff --git a/packages/extension/skills/SKILL.md b/packages/extension/skills/SKILL.md deleted file mode 100644 index 7def836..0000000 --- a/packages/extension/skills/SKILL.md +++ /dev/null @@ -1,176 +0,0 @@ ---- -name: report-a-bug -description: File a sanitized, intake-grade bug issue from a live Amicode session — auto-collected diagnostics, exactly one question, confirm gate, silent dedup, pin-aware upstream check for the vendored engine. Use when the user hits a bug in Amicode (the extension, the run gate, the Run Inspector, vetted templates, the engine, or a toolchain package) and wants to report it. -agents: [] -surface: public ---- - -# Report a Bug - -**Announce at start:** "I'm using the report-a-bug skill to file this." - -File an **intake-grade** bug issue from a live session. Capture stays cheap — auto-collected, sanitized diagnostics plus exactly one user question — and readiness is earned later at review, per the **maturity contract** below. This skill is capture-only: it files intake issues and publishes the contract a reviewer matures them by. Feature ideas, designs, and specs are out of scope — a bug filer has a symptom, not a resolved design. - -**The flow:** read the context envelope (when the session carries one) → classify the surface → capture (one question) → sanitize → dedup → upstream check (fork surfaces) → compose → confirm gate → file → print the filed sentinel. **Nothing posts before the confirm gate; the sentinel prints only after an actual filing.** - -## 0. Read the context envelope (when the session carries one) - -A bug session may carry a **context envelope** in its session metadata: a `bug_report` key with fields `project` (string, optional), `run_pointer` (string, optional), and `origin_session_id` (string, optional), written by the extension that spawned the session. **Read it first, silently — it precedes every context question and is never a user prompt.** - -- **Envelope present** → collect from the envelope *in place of* the live session: `project` seeds the step-1 surface classification; `run_pointer` (when present) is the run-id pointer for the step-2 diagnostics — platform/tier and the bounded log tail resolve locally through it. Never ask the user for run context the envelope already carries; the one content question (step 2) is still asked, unchanged. `origin_session_id` is local provenance for the session the bug came from — it is never posted. -- **Envelope absent** → collect from the live session exactly as the steps below describe; behavior is unchanged. -- **Pointer-only, always** — envelope run context travels as a pointer only: never expand `run_pointer` (or anything resolved through it) into absolute paths in any artifact, including the confirm-gate draft. Envelope content is already scrub-safe by construction; the step-3 sanitize pass applies to it unchanged. - -## 1. Classify the owning surface (silent — never a user prompt) - -Decide where the bug lives, from the session context: - -- **Product surfaces** — the extension, the run gate, the Run Inspector, the vetted templates → the public product repo (`harmoniqs/amicode`). -- **The vendored engine** (a fork-vendored component) → the fork repo (`harmoniqs/opencode`), and step 4's upstream check applies. -- **A toolchain package** → its owning repo. Repo inference for toolchain packages runs on the **internal path only** (step 7); on the public path the filing lands in the product repo and triage re-routes. - -## 2. Capture — exactly one content question - -Ask **one** question, via the `question` tool (free-form answer): **"What happened, and what did you expect?"** Everything else is auto-collected or deferred to maturation review. Never ask follow-ups at capture — gaps are the reviewer's job. (The dedup-hit and upstream-hit offers and the confirm gate are gates, not content questions.) - -**Auto-collect, locally, held unposted until scrubbed:** - -- Extension version (`code --list-extensions --show-versions`, or the installed `harmoniqs.amicode-*` extension directory name) and OS (`uname -srm`). -- The engine pin: `opencode.lock.json` under the installed extension directory — its `version`, `tag` (e.g. `v1.18.10-amicode.1`), and `repo`. -- **When a run is active** (a solve this session, or the bug is about a run): platform and tier from the problem workspace (`~/.amico/problems//` — `solvespec.json` / `events.jsonl`), the **run-id pointer** (`runs//`, relative to `~/.amico/` — a pointer, never an absolute path), and a **bounded log tail** (last ~40 lines of that run's `run.log`). When the session carries the `bug_report` envelope (step 0), this run context comes from the envelope's `run_pointer` instead of live-session discovery — same pointer form, same bounded tail. -- **Never collected:** absolute paths, binaries or screenshots, vault contents, and lab secrets (device frequencies, calibration values) — lab context travels as run-id pointers only. - -## 3. Sanitize — classify → scrub → compose - -Sanitization is **architectural, not procedural**: the draft is built only from scrubbed material, so sensitive content is absent by construction rather than caught by review. - -1. **Classify the taint** of the collected material: does the failure context touch proprietary code (private paid packages, private exemplars) or lab secrets? **Doubt tilts proprietary.** -2. **Scrub before any drafting.** Drop proprietary stack frames, symbols, source excerpts, and package names; lab secrets become run-id pointers. The scrub covers the auto-collected diagnostics **and the user's free-text answer**, and binds on every later search query (dedup, upstream) — a proprietary-flavored symptom never becomes a query string on a public tracker. -3. **Compose** the draft only from scrubbed material. - -Scrubbing is keyed to **content taint, not destination repo** — even a misrouted filing is a scrubbed filing. Proprietary diagnostics travel by pointer (the run id), never payload; the entitled reviewer follows the pointer locally. Marker wording differs by path (step 5 footer): public filings carry the bland `diagnostics: pointer-only`; internal filings may carry the explicit `sensitivity: proprietary-scrubbed`. - -## 4. Dedup + upstream check — silent unless they hit - -Both are read-only searches with **scrubbed terms only**, run before drafting so their results feed the footer. Silent on no match. If `gh` is unavailable or unauthenticated, skip both checks silently — the browser fallback in step 7 still files, and the footer records no upstream claim. - -- **Dedup.** Search the target repo's open issues (`gh search issues --repo --state open`). On a likely-match open issue, offer **comment-on-existing vs file-anyway** — the only conditional prompt besides the gates. -- **Upstream check (fork-vendored surfaces only — today, the vendored engine).** The pin makes "does the fix already exist upstream?" mechanically decidable: parse the release tag's upstream base (`v-amicode.` → `v` of `sst/opencode`), then search upstream issues **and** PRs with scrubbed terms: - - **A matching merged fix** (merged PR / closed-as-fixed issue) at a release newer than the vendored base → offer an **upstream-bump chore issue instead of the `BUG:` filing** (merge the newer upstream; never re-implement what upstream already fixed). - - **A matching open upstream issue** → file the intake bug normally, footer `upstream: sst/opencode#N (open)` — maturation watches rather than implements. - - **No trace** → footer `upstream: none found`. - -## 5. Compose the intake issue - -**Title:** `BUG: ` - -**Body:** - -``` -## Summary — one paragraph, from the scrubbed answer -## Context — extension version, OS, engine base; what was happening -## Expected vs actual — the scrubbed expected/actual pair -
Diagnostics - -- platform / tier, run-id pointer (runs//) when a run is active -- bounded, scrubbed log tail -- no absolute paths, no binary uploads - -
- ---- -intake: not-ready — mature per the maturity contract before picking up -suggested_path: A | B -diagnostics: inline | pointer-only (public path) -sensitivity: proprietary-scrubbed (internal path, when scrubbed) -upstream: n/a | none found | sst/opencode#N (open) -``` - -The `intake: not-ready` footer (plus the intake label/column on the internal path) is what makes the issue **visibly not-ready**. `suggested_path` is the agent's maturity-path suggestion: `A` only when all four Path-A criteria below are clearly met from the filed diagnostics; **any uncertainty → `B`**; the human can veto it at the confirm gate. - -## 6. Confirm gate — nothing posts before it - -Show the user **the exact final body, the target repo, and the `suggested_path`** — the draft is already scrubbed, so nothing proprietary is displayed either. Then ask via the `question` tool with **three explicit options** plus a free-text field for modifications: - -| Option | Behavior | -|---|---| -| **File it** | File exactly as drafted — proceed to step 7 | -| **Edit with notes** | Apply any modifications the user typed in the free-text answer field, then file — proceed to step 7 | -| **Veto — don't file** | File nothing, print no sentinel, the session closes cleanly | - -**The free-text answer field is always available** — the user can type modifications regardless of which option they pick. When "Edit with notes" is chosen, incorporate their text into the draft before filing. A veto files nothing — and with no filing, **no sentinel is ever printed** (step 7's terminal line exists only after an actual filing). No issue, comment, or unscrubbed query leaves the machine before this gate. - -## 7. File — the runtime org-tail fork - -One skill for every filer. The **org tail** (private-repo routing, board placement, intake labels) runs only when **both** hold at filing time; otherwise the public path runs: - -- **Checkout presence** — internal-surface skills are staged in the session's skill index (e.g. `write-an-issue` is resolvable). Checkout presence is the eligibility proof: internal skill content exists only in a private plugin checkout. -- **Org access** — `gh auth status` succeeds and the org board is readable. - -**Public path (default).** File to the public product repo (`harmoniqs/amicode`) with a `bug` label (resolve the label live; file without it if absent) and **no board placement**. Public filings always land there — even when the symptom points at a public toolchain package; triage re-routes. **No `gh` auth?** Fall back to opening a pre-filled new-issue URL in the browser (`https://github.com/harmoniqs/amicode/issues/new?title=…&body=…`); URLs have a practical length limit, so truncate the diagnostics block and keep the run-id pointer. - -**Internal path (org tail).** Route to the owning repo per step 1 (product surfaces → `harmoniqs/amicode`; engine → `harmoniqs/opencode`; toolchain packages → their owning repos, including private ones — private-repo routing is the point of this path). File **unassigned**, with labels resolved live (`bug` plus an intake-flavored marker such as `intake`/`triage` if it exists — never create labels; fall back to board-column-only marking). Then place the issue on the org SCRUM board (GitHub Projects, org `harmoniqs`, number `4`) in an **intake-flavored column**: read the live Status options (never hardcode), pick the option whose name matches intake/triage, fall back to **Backlog**: - -```bash -# a. live Status options → project id, field id, option ids -gh api graphql -f query='query($org:String!, $num:Int!){ organization(login:$org){ - projectV2(number:$num){ id field(name:"Status"){ ... on ProjectV2SingleSelectField { - id options { id name } } } } } }' -f org=harmoniqs -F num=4 -# b. add the issue → item id -gh api graphql -f query='mutation($project:ID!, $content:ID!){ addProjectV2ItemById( - input:{projectId:$project, contentId:$content}){ item { id } } }' \ - -f project= -f content= -# c. set the Status column on the new item -gh api graphql -f query='mutation($project:ID!, $item:ID!, $field:ID!, $opt:String!){ - updateProjectV2ItemFieldValue(input:{ projectId:$project, itemId:$item, - fieldId:$field, value:{ singleSelectOptionId:$opt } }){ projectV2Item { id } } }' \ - -f project= -f item= -f field= -f opt= -``` - -`` = `gh issue view --repo --json id --jq .id`. No status or assignee prompts — the intake defaults are fixed (intake column, unassigned). - -**The filed sentinel — the terminal contract.** After an **actual filing** — and only then — close the flow by printing the sentinel line, the run-telemetry idiom's one terminal line: - -- **Filed via `gh`** (public or internal path — the issue created, and board-placed on the internal path): print `AMICODE_BUG_FILED ` with the new issue's URL, e.g. - `AMICODE_BUG_FILED https://github.com/harmoniqs/amicode/issues/123`. -- **Filed via the browser fallback**: print the sentinel carrying `filed-via-browser` — or the pre-filled new-issue URL as the token when one was opened. - -This line is a machine contract, not prose — the dock watcher matches `/^AMICODE_BUG_FILED[ \t]+(\S+)/m` against the streamed message text. So: the sentinel is **its own line**, the marker and its single token separated by spaces or tabs, and nothing else on the line (no markdown, no punctuation glued to the token). **Never print it at or before the confirm gate** — a veto means nothing was filed and no sentinel ever appears in the session. The sentinel is the lifecycle trigger that archives the bug session (ADR `docs/adr/0004-bug-session-lifecycle.md` in `harmoniqs/amicode`); printing it without a filing would archive a session whose bug never reached the tracker. - -## The maturity contract (the reviewer-facing interface) - -Intake issues **mature in place** to ready-for-agent, through one of two paths delineated by bug shape: - -- **Path A (reviewer agent / light review)** requires **all four** criteria: **(1)** deterministic repro from the filed diagnostics; **(2)** single owning surface; **(3)** no contract at stake; **(4)** the fix needs no design choice. -- **Path B** is everything else: escalate to HITL through the design pipeline (brainstorming + a grilling skill). **Any uncertainty resolves to HITL** — the cost of misrouting is asymmetric. - -**Designation flow:** the agent suggests `suggested_path` at filing → the call is **re-checked against the four criteria at review** → the human holds a **veto at the confirm gate**. The whole flow tilts toward the human. - -**Exit format:** maturation rewrites the issue in place to the **bug-variant decision surface** — the canonical two-reader layout (`> [!IMPORTANT]` decision surface over execution detail), `BUG:` title prefix preserved. `write-an-issue` is the format authority for the exit state; it ships `surface: internal`, so only team reviewers execute maturation — never invoke it from this skill. - -**Footer fields** the reviewer consumes: `suggested_path`, the diagnostics marker (`diagnostics: pointer-only` / `sensitivity: proprietary-scrubbed`), and the `upstream` status. - -**Two reviewer clauses:** - -- **Fork-surface upstream re-check.** For bugs on a fork-vendored surface, the reviewer re-checks upstream at review time. A fix landed since filing → close the bug as **fixed-by-upstream** and spawn the bump chore. -- **Entitled review of scrubbed filings.** A proprietary-scrubbed filing cannot satisfy Path A's "repro from the filed diagnostics" without following the run-id pointer to full diagnostics — which correctly forces an entitled human to hold the review. - -Until a reviewer-agent skill exists to execute maturation, humans mature intake issues. - -## Invariants - -- **Exactly one content question** at capture; everything else is auto-collected, a gate, or a conditional offer. -- **Nothing posts before the confirm gate** — and the gate's draft is already scrubbed. -- The **filed sentinel prints only after an actual filing** — never at the confirm gate; a veto means no sentinel, ever. One line, one token, matching `/^AMICODE_BUG_FILED[ \t]+(\S+)/m`. -- **Envelope context is pointer-only** — `run_pointer` is never expanded into absolute paths in any artifact, including the confirm-gate draft; envelope absent → live-session collection, unchanged. -- Intake issues are **visibly marked not-ready** (footer; label + column on the internal path). -- **No proprietary implementation details or package names** in any artifact that leaves the machine — including the draft shown to the user. -- **No lab secrets in payloads** — run-id pointers only. -- The **org tail never runs** without both checkout presence and org access. -- **Public searches carry scrubbed terms only** (dedup and upstream alike). - -## Composition - -- `write-an-issue` — the format authority for the maturity exit state (internal; not staged on public installs, and not invoked by this skill). -- The design pipeline (`brainstorming` + a grilling skill) — the Path-B destination. -- The idea hopper explicitly excludes bugs — this skill is why. From ac9872634db9fc43eb686091583278cb812e4a8a Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Wed, 5 Aug 2026 21:49:36 -0400 Subject: [PATCH 20/22] bug reporter: deny question tool, free-form dialogue only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createSession passes permission: [{question, deny, *}] — the question tool is invisible to the model (hard guardrail, same as CLI non-interactive). - Skill step 2 (capture) and step 6 (confirm gate): 'via the question tool' replaced with 'output as plain text, wait for the user's reply.' - Explicit 'Do not use the question tool' directive in both steps. - Option A: at least one content question, accepts user follow-ups. --- .../extension/skills/report-a-bug/SKILL.md | 18 +++++++++++------- packages/extension/src/bug_report.ts | 10 +++++++++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/extension/skills/report-a-bug/SKILL.md b/packages/extension/skills/report-a-bug/SKILL.md index 1a66765..f0f21fa 100644 --- a/packages/extension/skills/report-a-bug/SKILL.md +++ b/packages/extension/skills/report-a-bug/SKILL.md @@ -1,6 +1,6 @@ --- name: report-a-bug -description: File a sanitized, intake-grade bug issue from a live Amicode session — auto-collected diagnostics, exactly one question, confirm gate, silent dedup, pin-aware upstream check for the vendored engine. Use when the user hits a bug in Amicode (the extension, the run gate, the Run Inspector, vetted templates, the engine, or a toolchain package) and wants to report it. +description: File a sanitized, intake-grade bug issue from a live Amicode session — auto-collected diagnostics, at least one question (accepts user follow-ups), confirm gate, silent dedup, pin-aware upstream check for the vendored engine. Use when the user hits a bug in Amicode (the extension, the run gate, the Run Inspector, vetted templates, the engine, or a toolchain package) and wants to report it. agents: [] surface: public --- @@ -9,9 +9,9 @@ surface: public **Announce at start:** "I'm using the report-a-bug skill to file this." -File an **intake-grade** bug issue from a live session. Capture stays cheap — auto-collected, sanitized diagnostics plus exactly one user question — and readiness is earned later at review, per the **maturity contract** below. This skill is capture-only: it files intake issues and publishes the contract a reviewer matures them by. Feature ideas, designs, and specs are out of scope — a bug filer has a symptom, not a resolved design. +File an **intake-grade** bug issue from a live session. Capture stays cheap — auto-collected, sanitized diagnostics plus at least one user question — and readiness is earned later at review, per the **maturity contract** below. This skill is capture-only: it files intake issues and publishes the contract a reviewer matures them by. Feature ideas, designs, and specs are out of scope — a bug filer has a symptom, not a resolved design. -**The flow:** read the context envelope (when the session carries one) → classify the surface → capture (one question) → sanitize → dedup → upstream check (fork surfaces) → compose → confirm gate → file → print the filed sentinel. **Nothing posts before the confirm gate; the sentinel prints only after an actual filing.** +**The flow:** read the context envelope (when the session carries one) → classify the surface → capture (at least one question) → sanitize → dedup → upstream check (fork surfaces) → compose → confirm gate → file → print the filed sentinel. **Nothing posts before the confirm gate; the sentinel prints only after an actual filing.** ## 0. Read the context envelope (when the session carries one) @@ -29,9 +29,13 @@ Decide where the bug lives, from the session context: - **The vendored engine** (a fork-vendored component) → the fork repo (`harmoniqs/opencode`), and step 4's upstream check applies. - **A toolchain package** → its owning repo. Repo inference for toolchain packages runs on the **internal path only** (step 7); on the public path the filing lands in the product repo and triage re-routes. -## 2. Capture — exactly one content question +## 2. Capture — at least one content question, plus user-initiated follow-ups -Ask **one** question, via the `question` tool (free-form answer): **"What happened, and what did you expect?"** Everything else is auto-collected or deferred to maturation review. Never ask follow-ups at capture — gaps are the reviewer's job. (The dedup-hit and upstream-hit offers and the confirm gate are gates, not content questions.) +Ask the user directly, as plain text output: **"What happened, and what did you expect?"** Do not use the `question` tool — the bug dock handles the dialogue natively. Output the question as your message text and wait for the user's reply. Everything else is auto-collected or deferred to maturation review. Never ask follow-up questions yourself — gaps are the reviewer's job. + +**After this question, the user may send unsolicited follow-up messages** through the always-available textbox — additional context, clarifications, or corrections. Accept them silently (never ask another question) and fold them into the draft as extra detail. The user drives any follow-up; the agent never escalates. + +(The dedup-hit and upstream-hit offers and the confirm gate are gates, not content questions.) **Auto-collect, locally, held unposted until scrubbed:** @@ -90,7 +94,7 @@ The `intake: not-ready` footer (plus the intake label/column on the internal pat ## 6. Confirm gate — nothing posts before it -Show the user **the exact final body, the target repo, and the `suggested_path`** — the draft is already scrubbed, so nothing proprietary is displayed either. Ask **one** question, via the `question` tool (free-form answer): **"The draft above is ready. Reply `file it` to submit as a new issue, `edit: ` to modify before filing, `comment: # ` to add a clarifying comment to an existing issue, or `veto` to cancel."** Never ask follow-ups — the confirm gate is a single question. +Show the user **the exact final body, the target repo, and the `suggested_path`** — the draft is already scrubbed, so nothing proprietary is displayed either. Tell the user, as plain text output: **"The draft above is ready. Reply `file it` to submit as a new issue, `edit: ` to modify before filing, `comment: # ` to add a clarifying comment to an existing issue, or `veto` to cancel."** Do not use the `question` tool. Wait for the user's reply. Never ask follow-ups — the confirm gate is a single prompt. On answer: starts with "file it" → file exactly as drafted (step 7). Starts with "edit:" → incorporate the text after "edit:" and file (step 7). Starts with "comment:" → parse `# ` (e.g. `comment: harmoniqs/amicode#123 Thanks — confirmed on my end`), post the comment via `gh issue comment --repo --body `, print the sentinel with the issue's URL, and end. Starts with "veto" → file nothing, no sentinel. A veto files nothing — and with no filing, **no sentinel is ever printed** (step 7's terminal line exists only after an actual filing). No issue, comment, or unscrubbed query leaves the machine before this gate. @@ -153,7 +157,7 @@ Until a reviewer-agent skill exists to execute maturation, humans mature intake ## Invariants -- **Exactly one content question** at capture; everything else is auto-collected, a gate, or a conditional offer. +- **At least one content question** at capture — the agent asks exactly one opening question and accepts unsolicited user follow-ups as additional context; never asks a second question itself. Everything else is auto-collected, a gate, or a conditional offer. - **Nothing posts before the confirm gate** — and the gate's draft is already scrubbed. - The **filed sentinel prints only after an actual filing** — never at the confirm gate; a veto means no sentinel, ever. One line, one token, matching `/^AMICODE_BUG_FILED[ \t]+(\S+)/m`. - **Envelope context is pointer-only** — `run_pointer` is never expanded into absolute paths in any artifact, including the confirm-gate draft; envelope absent → live-session collection, unchanged. diff --git a/packages/extension/src/bug_report.ts b/packages/extension/src/bug_report.ts index a6f2ca1..7c57d6d 100644 --- a/packages/extension/src/bug_report.ts +++ b/packages/extension/src/bug_report.ts @@ -244,7 +244,15 @@ export class BugReportManager { private async createSession(server: BugReportServer, envelope: Record): Promise { const res = await this.fetch(this.collectionUrl(server), server, { method: "POST", - body: { title: BUG_REPORT_TITLE, metadata: { bug_report: envelope } }, + body: { + title: BUG_REPORT_TITLE, + metadata: { bug_report: envelope }, + // Hard guardrail: the question tool is hidden from the model for + // bug sessions (amicode#249). The bug dock handles dialogue via the + // permanent textarea + session.prompt, not the question tool's + // structured Q&A. Same pattern as the CLI's non-interactive mode. + permission: [{ permission: "question", pattern: "*", action: "deny" }], + }, }); const body = res.ok ? ((await res.json()) as { id?: unknown }) : undefined; if (!body || typeof body.id !== "string" || body.id === "") { From 11eb0cfbd1b2b6146fbc95cf69c603431d48280a Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Wed, 5 Aug 2026 23:39:21 -0400 Subject: [PATCH 21/22] skill: sentinel prints after ANY GitHub action, explicit format for comment path - Sentinel is a lifecycle signal, not a semantic bug-classification judgment. Prints after issue created, comment posted, OR chore filed. - Comment path: explicit AMICODE_BUG_FILED format with full issue URL (construct from repo + number). - Removed 'actual filing' language that made the model skip the sentinel for comments. - Invariants updated: 'any successful GitHub action' replaces 'actual filing'. --- packages/extension/skills/report-a-bug/SKILL.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/extension/skills/report-a-bug/SKILL.md b/packages/extension/skills/report-a-bug/SKILL.md index f0f21fa..9f5db66 100644 --- a/packages/extension/skills/report-a-bug/SKILL.md +++ b/packages/extension/skills/report-a-bug/SKILL.md @@ -11,7 +11,7 @@ surface: public File an **intake-grade** bug issue from a live session. Capture stays cheap — auto-collected, sanitized diagnostics plus at least one user question — and readiness is earned later at review, per the **maturity contract** below. This skill is capture-only: it files intake issues and publishes the contract a reviewer matures them by. Feature ideas, designs, and specs are out of scope — a bug filer has a symptom, not a resolved design. -**The flow:** read the context envelope (when the session carries one) → classify the surface → capture (at least one question) → sanitize → dedup → upstream check (fork surfaces) → compose → confirm gate → file → print the filed sentinel. **Nothing posts before the confirm gate; the sentinel prints only after an actual filing.** +**The flow:** read the context envelope (when the session carries one) → classify the surface → capture (at least one question) → sanitize → dedup → upstream check (fork surfaces) → compose → confirm gate → file → print the filed sentinel. **Nothing posts before the confirm gate; the sentinel prints after any successful GitHub action (issue created OR comment posted).** ## 0. Read the context envelope (when the session carries one) @@ -96,7 +96,7 @@ The `intake: not-ready` footer (plus the intake label/column on the internal pat Show the user **the exact final body, the target repo, and the `suggested_path`** — the draft is already scrubbed, so nothing proprietary is displayed either. Tell the user, as plain text output: **"The draft above is ready. Reply `file it` to submit as a new issue, `edit: ` to modify before filing, `comment: # ` to add a clarifying comment to an existing issue, or `veto` to cancel."** Do not use the `question` tool. Wait for the user's reply. Never ask follow-ups — the confirm gate is a single prompt. -On answer: starts with "file it" → file exactly as drafted (step 7). Starts with "edit:" → incorporate the text after "edit:" and file (step 7). Starts with "comment:" → parse `# ` (e.g. `comment: harmoniqs/amicode#123 Thanks — confirmed on my end`), post the comment via `gh issue comment --repo --body `, print the sentinel with the issue's URL, and end. Starts with "veto" → file nothing, no sentinel. A veto files nothing — and with no filing, **no sentinel is ever printed** (step 7's terminal line exists only after an actual filing). No issue, comment, or unscrubbed query leaves the machine before this gate. +On answer: starts with "file it" → file exactly as drafted (step 7). Starts with "edit:" → incorporate the text after "edit:" and file (step 7). Starts with "comment:" → parse `# ` (e.g. `comment: harmoniqs/amicode#123 Thanks — confirmed on my end`), post the comment via `gh issue comment --repo --body `, then **print the sentinel on its own line exactly as**: `AMICODE_BUG_FILED https://github.com//issues/` (the full URL of the issue you commented on — construct it from the repo and issue number), and end. Starts with "veto" → file nothing, no sentinel. **The sentinel prints after EVERY successful action — new issue OR comment. The only path that skips the sentinel is "veto."** No issue, comment, or unscrubbed query leaves the machine before this gate. ## 7. File — the runtime org-tail fork @@ -127,13 +127,14 @@ gh api graphql -f query='mutation($project:ID!, $item:ID!, $field:ID!, $opt:Stri `` = `gh issue view --repo --json id --jq .id`. No status or assignee prompts — the intake defaults are fixed (intake column, unassigned). -**The filed sentinel — the terminal contract.** After an **actual filing** — and only then — close the flow by printing the sentinel line, the run-telemetry idiom's one terminal line: +**The filed sentinel — the terminal contract.** This is a **lifecycle signal**, not a semantic judgment. Print it after ANY successful GitHub action in this session — new issue created, comment posted, chore filed — regardless of whether the content is a "bug." The sentinel tells the dock "you're done; show the end-state." Without it the dock stays open forever. -- **Filed via `gh`** (public or internal path — the issue created, and board-placed on the internal path): print `AMICODE_BUG_FILED ` with the new issue's URL, e.g. +- **After creating an issue via `gh`**: print `AMICODE_BUG_FILED ` with the new issue's URL, e.g. `AMICODE_BUG_FILED https://github.com/harmoniqs/amicode/issues/123`. -- **Filed via the browser fallback**: print the sentinel carrying `filed-via-browser` — or the pre-filled new-issue URL as the token when one was opened. +- **After posting a comment via `gh issue comment`**: print `AMICODE_BUG_FILED https://github.com//issues/` with the issue's URL. +- **After the browser fallback**: print the sentinel carrying `filed-via-browser` — or the pre-filled new-issue URL as the token when one was opened. -This line is a machine contract, not prose — the dock watcher matches `/^AMICODE_BUG_FILED[ \t]+(\S+)/m` against the streamed message text. So: the sentinel is **its own line**, the marker and its single token separated by spaces or tabs, and nothing else on the line (no markdown, no punctuation glued to the token). **Never print it at or before the confirm gate** — a veto means nothing was filed and no sentinel ever appears in the session. The sentinel is the lifecycle trigger that archives the bug session (ADR `docs/adr/0004-bug-session-lifecycle.md` in `harmoniqs/amicode`); printing it without a filing would archive a session whose bug never reached the tracker. +This line is a machine contract, not prose. The sentinel is **its own line** in your text output, the marker and its single token separated by a space, and nothing else on the line (no markdown, no backticks, no punctuation glued to the token). **Never print it before a successful GitHub action** — a veto means nothing was posted and no sentinel ever appears. The sentinel is the lifecycle trigger that archives the bug session; printing it without an action would archive a session whose report never reached GitHub. ## The maturity contract (the reviewer-facing interface) @@ -159,7 +160,7 @@ Until a reviewer-agent skill exists to execute maturation, humans mature intake - **At least one content question** at capture — the agent asks exactly one opening question and accepts unsolicited user follow-ups as additional context; never asks a second question itself. Everything else is auto-collected, a gate, or a conditional offer. - **Nothing posts before the confirm gate** — and the gate's draft is already scrubbed. -- The **filed sentinel prints only after an actual filing** — never at the confirm gate; a veto means no sentinel, ever. One line, one token, matching `/^AMICODE_BUG_FILED[ \t]+(\S+)/m`. +- The **filed sentinel prints after any successful GitHub action** (issue created, comment posted, chore filed) — never before the confirm gate; a veto means no sentinel, ever. The sentinel is a lifecycle signal, not a semantic judgment about whether the content is a "bug." - **Envelope context is pointer-only** — `run_pointer` is never expanded into absolute paths in any artifact, including the confirm-gate draft; envelope absent → live-session collection, unchanged. - Intake issues are **visibly marked not-ready** (footer; label + column on the internal path). - **No proprietary implementation details or package names** in any artifact that leaves the machine — including the draft shown to the user. From bf11d7ffd6858567f542a295f5d8cf71d092e6d2 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Thu, 6 Aug 2026 20:19:01 -0400 Subject: [PATCH 22/22] feat: bug report uses the server's default model (no hardcoded model) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - armSession omits the model field entirely — the server uses whatever model the user has configured globally (their defaultModel or the server's built-in default). - Removed findOriginModel (session.model isn't set by the model picker, only at session create — so it's always empty for picker-users). - No amicode.bugReportModel setting (unnecessary complexity). --- packages/extension/src/bug_report.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/bug_report.ts b/packages/extension/src/bug_report.ts index 7c57d6d..ff43bd4 100644 --- a/packages/extension/src/bug_report.ts +++ b/packages/extension/src/bug_report.ts @@ -262,11 +262,11 @@ export class BugReportManager { } /** Arm: the report-a-bug slash command as the session's first turn. - * Pinned to deepseek-v4-pro — cheap, fast, no OpenAI/Claude (amicode#249). */ + * Uses the server's default model (whatever the user has configured). */ private async armSession(server: BugReportServer, sessionID: string): Promise { const res = await this.fetch(new URL(`/session/${sessionID}/command`, server.url), server, { method: "POST", - body: { command: REPORT_A_BUG_SKILL, arguments: "", model: "opencode/deepseek-v4-pro" }, + body: { command: REPORT_A_BUG_SKILL, arguments: "" }, }); if (!res.ok) throw new Error(`couldn't arm the report-a-bug skill (HTTP ${res.status})`); }