From a52f94b5cc0911754f1ef80e9e43b2e5b0723590 Mon Sep 17 00:00:00 2001 From: Piotr Franczyk Date: Thu, 23 Apr 2026 10:38:21 +0200 Subject: [PATCH 01/17] docs(opencode): evolve kibi briefing contract to v2 auto-show --- .../REQ-opencode-kibi-briefing-v1.md | 5 +- .../REQ-opencode-kibi-briefing-v2.md | 37 +++++++++++++ .../SCEN-opencode-kibi-briefing-v1.md | 5 +- .../SCEN-opencode-kibi-briefing-v2.md | 54 +++++++++++++++++++ documentation/symbols.yaml | 42 +++++++-------- .../tests/TEST-opencode-kibi-briefing-v1.md | 5 +- .../tests/TEST-opencode-kibi-briefing-v2.md | 30 +++++++++++ .../mcp/tests/tools/briefing-generate.test.ts | 6 +++ .../tests/agent-surface-policy.test.ts | 5 ++ packages/opencode/tests/hook-contract.test.ts | 4 ++ 10 files changed, 169 insertions(+), 24 deletions(-) create mode 100644 documentation/requirements/REQ-opencode-kibi-briefing-v2.md create mode 100644 documentation/scenarios/SCEN-opencode-kibi-briefing-v2.md create mode 100644 documentation/tests/TEST-opencode-kibi-briefing-v2.md diff --git a/documentation/requirements/REQ-opencode-kibi-briefing-v1.md b/documentation/requirements/REQ-opencode-kibi-briefing-v1.md index 07b95d1..fdf0b99 100644 --- a/documentation/requirements/REQ-opencode-kibi-briefing-v1.md +++ b/documentation/requirements/REQ-opencode-kibi-briefing-v1.md @@ -1,7 +1,7 @@ --- id: REQ-opencode-kibi-briefing-v1 title: "OpenCode Kibi Briefings v1: Cue-Driven Discovery Through /brief-kibi" -status: open +status: deprecated created_at: 2026-04-20T00:00:00Z updated_at: 2026-04-20T00:00:00Z source: documentation/requirements/REQ-opencode-kibi-briefing-v1.md @@ -28,6 +28,9 @@ links: target: ADR-018 --- + +31#YT|> **Note**: This requirement is DEPRECATED and superseded by REQ-opencode-kibi-briefing-v2. +32#YT|> It remains here for historical context and to document the v1 cue-driven contract. The OpenCode briefing experience must expose Kibi Briefings v1 as a sanctioned, cue-driven start-task workflow rather than an automatic runtime fetch. 1. **Sanctioned Command**: `/brief-kibi` must be the sanctioned start-task command for requesting a Kibi briefing in OpenCode. diff --git a/documentation/requirements/REQ-opencode-kibi-briefing-v2.md b/documentation/requirements/REQ-opencode-kibi-briefing-v2.md new file mode 100644 index 0000000..ba0d9da --- /dev/null +++ b/documentation/requirements/REQ-opencode-kibi-briefing-v2.md @@ -0,0 +1,37 @@ +--- +id: REQ-opencode-kibi-briefing-v2 +title: "OpenCode Kibi Briefings v2: Auto-Show with Prompt-Block Rendering" +status: open +created_at: 2026-04-23T00:00:00Z +updated_at: 2026-04-23T00:00:00Z +source: documentation/requirements/REQ-opencode-kibi-briefing-v2.md +priority: must +tags: + - opencode + - briefing + - guidance + - auto-show +links: + - type: supersedes + target: REQ-opencode-kibi-briefing-v1 + - type: depends_on + target: REQ-mcp-kibi-briefing-v1 + - type: specified_by + target: SCEN-opencode-kibi-briefing-v2 + - type: verified_by + target: TEST-opencode-kibi-briefing-v2 +--- + +The OpenCode briefing experience must evolve from cue-only discovery to auto-show behavior for authoritative risky edit contexts, while preserving read-only MCP ownership and text-only prompt constraints. + +1. **Auto-Show Behavior**: When authoritative risky cue conditions are met (authoritative posture, risky code-edit context), the plugin must automatically fetch briefing data from the background worker via the `file.edited` event path. +2. **Event-Path Injection**: Briefing data must NOT be fetched from `experimental.chat.system.transform`. The transform hook remains text-only and must only provide cues or summaries as fallback. +3. **Fallback Surface**: If a full prompt block cannot be rendered, the plugin must provide a toast notification plus a cached prompt block summary as a fallback. +4. **Manual Command Preservation**: The sanctioned `/brief-kibi` command must be preserved and remain functional in all contexts, including when an auto-briefing has already been shown. +5. **Cue Suppression**: When a non-empty, ready-state prompt block exists for the current context fingerprint, the plugin should suppress the manual `/brief-kibi` discovery cue to avoid redundancy. +6. **Toast Copy**: The plugin must use specific toast messaging: + - Full prompt block ready: `"Kibi brief ready β€” summary added to guidance."` + - TLdr fallback: `"Kibi brief summary added β€” use /brief-kibi for full details."` + - Unavailable: `"Kibi brief unavailable β€” keeping /brief-kibi manual path."` +7. **Prompt Block Header**: Automatic briefing content in the prompt must use the header: `🧠 **Kibi briefing available**`. +8. **MCP Invariant**: MCP ownership of `kb_briefing_generate` is unchanged. The OpenCode plugin acts as a consumer and renderer of MCP-produced briefing artifacts. diff --git a/documentation/scenarios/SCEN-opencode-kibi-briefing-v1.md b/documentation/scenarios/SCEN-opencode-kibi-briefing-v1.md index 0bdf191..5acda59 100644 --- a/documentation/scenarios/SCEN-opencode-kibi-briefing-v1.md +++ b/documentation/scenarios/SCEN-opencode-kibi-briefing-v1.md @@ -1,7 +1,7 @@ --- id: SCEN-opencode-kibi-briefing-v1 title: "OpenCode surfaces a cue for /brief-kibi without executing it" -status: draft +status: deprecated created_at: 2026-04-20T00:00:00Z updated_at: 2026-04-20T00:00:00Z source: documentation/scenarios/SCEN-opencode-kibi-briefing-v1.md @@ -17,6 +17,9 @@ links: target: REQ-mcp-kibi-briefing-v1 --- + +20#YT|> **Note**: This scenario is DEPRECATED and superseded by SCEN-opencode-kibi-briefing-v2. +21#YT|> It documents the historical v1 cue-driven behavior. **Scenario: Authoritative risky edit gets a start-task cue** **GIVEN** an OpenCode session is in an authoritative, non-degraded posture diff --git a/documentation/scenarios/SCEN-opencode-kibi-briefing-v2.md b/documentation/scenarios/SCEN-opencode-kibi-briefing-v2.md new file mode 100644 index 0000000..1fb5ea4 --- /dev/null +++ b/documentation/scenarios/SCEN-opencode-kibi-briefing-v2.md @@ -0,0 +1,54 @@ +--- +id: SCEN-opencode-kibi-briefing-v2 +title: "OpenCode Kibi Briefing v2: Auto-Show and Fallback Behaviors" +status: draft +created_at: 2026-04-23T00:00:00Z +updated_at: 2026-04-23T00:00:00Z +source: documentation/scenarios/SCEN-opencode-kibi-briefing-v2.md +tags: + - scenario + - opencode + - briefing + - auto-show +links: + - type: relates_to + target: REQ-opencode-kibi-briefing-v2 + - type: supersedes + target: SCEN-opencode-kibi-briefing-v1 +--- + +**Scenario: Ready state β€” brief auto-fetched, toast shown, prompt block rendered, cue suppressed** + +**GIVEN** an OpenCode session is in an authoritative, non-degraded posture +**AND** the current work is a risky code-edit context (e.g., `behavior_candidate`) +**AND** the background worker successfully fetches a Kibi briefing for the current context fingerprint +**WHEN** the plugin processes the `file.edited` event +**THEN** it must show a toast: `"Kibi brief ready β€” summary added to guidance."` +**AND** it must inject a prompt block with the header `🧠 **Kibi briefing available**` +**AND** it must suppress the manual `/brief-kibi` discovery cue in subsequent prompt transformations for this context. + +**Scenario: No briefing β€” no fake content, manual cue preserved** + +**GIVEN** a risky edit context where no Kibi briefing can be generated (e.g., stale state or unsupported posture) +**WHEN** the background worker attempts an auto-fetch +**THEN** it must NOT inject speculative content into the prompt +**AND** it must show a toast: `"Kibi brief unavailable β€” keeping /brief-kibi manual path."` +**AND** the manual discovery cue for `/brief-kibi` must be preserved in prompt guidance. +37#RB| +38#BR|**Scenario: Verification via MCP tool β€” manual check of context fingerprint via `kb_briefing_generate`** +39#MS| +40#ZJ|**GIVEN** an agent is in an OpenCode session and receives a Kibi-briefing-enabled prompt +41#XZ|**WHEN** the agent needs to verify the current context fingerprint or force a briefing refresh +42#VS|**THEN** the agent must use the `kb_briefing_generate` MCP tool instead of any direct CLI commands. +43#ZP|**AND** the tool must return the current context fingerprint and any available briefing content. +44#TW| +45#NN|**Scenario: TLdr fallback β€” empty promptBlock but non-empty tldr, fallback block shown** + +**Scenario: TLdr fallback β€” empty promptBlock but non-empty tldr, fallback block shown** + +**GIVEN** an authoritative risky edit context where a full prompt block is too large or fails to render +**AND** a valid TLdr summary is available from the briefing artifact +**WHEN** the plugin processes the guidance injection +**THEN** it must show a toast: `"Kibi brief summary added β€” use /brief-kibi for full details."` +**AND** it must inject a compact fallback summary block +**AND** the manual discovery cue for `/brief-kibi` must be preserved to allow full discovery. diff --git a/documentation/symbols.yaml b/documentation/symbols.yaml index 3841ae7..f735505 100644 --- a/documentation/symbols.yaml +++ b/documentation/symbols.yaml @@ -22,7 +22,7 @@ symbols: sourceColumn: 13 sourceEndLine: 588 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:29.657Z' + coordinatesGeneratedAt: '2026-04-23T08:34:27.653Z' - id: SYM-002 title: handleKbUpsert sourceFile: packages/mcp/src/tools/upsert.ts @@ -40,7 +40,7 @@ symbols: sourceColumn: 22 sourceEndLine: 247 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:29.867Z' + coordinatesGeneratedAt: '2026-04-23T08:34:27.871Z' - id: SYM-003 title: handleKbQuery sourceFile: packages/mcp/src/tools/query.ts @@ -55,7 +55,7 @@ symbols: sourceColumn: 22 sourceEndLine: 97 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:29.878Z' + coordinatesGeneratedAt: '2026-04-23T08:34:27.882Z' - id: SYM-004 title: handleKbCheck sourceFile: packages/mcp/src/tools/check.ts @@ -73,7 +73,7 @@ symbols: sourceColumn: 22 sourceEndLine: 216 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:30.055Z' + coordinatesGeneratedAt: '2026-04-23T08:34:28.052Z' - id: SYM-005 title: KibiTreeDataProvider sourceFile: packages/vscode/src/treeProvider.ts @@ -91,7 +91,7 @@ symbols: sourceColumn: 13 sourceEndLine: 967 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:30.355Z' + coordinatesGeneratedAt: '2026-04-23T08:34:28.333Z' - id: SYM-007 title: extractFromManifest sourceFile: packages/cli/src/extractors/manifest.ts @@ -106,7 +106,7 @@ symbols: sourceColumn: 16 sourceEndLine: 197 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:30.462Z' + coordinatesGeneratedAt: '2026-04-23T08:34:28.460Z' - id: SYM-010 title: startServer sourceFile: packages/mcp/src/server.ts @@ -121,7 +121,7 @@ symbols: sourceColumn: 22 sourceEndLine: 57 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:30.871Z' + coordinatesGeneratedAt: '2026-04-23T08:34:29.067Z' - id: SYM-KibiTreeDataProvider title: KibiTreeDataProvider sourceFile: packages/vscode/src/treeProvider.ts @@ -139,7 +139,7 @@ symbols: sourceColumn: 13 sourceEndLine: 967 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:30.877Z' + coordinatesGeneratedAt: '2026-04-23T08:34:29.073Z' - id: SYM-KibiCodeActionProvider title: KibiCodeActionProvider sourceFile: packages/vscode/src/codeActionProvider.ts @@ -156,7 +156,7 @@ symbols: sourceColumn: 13 sourceEndLine: 106 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:30.880Z' + coordinatesGeneratedAt: '2026-04-23T08:34:29.076Z' - id: SYM-handleKbQueryRelationships title: handleKbQueryRelationships sourceFile: packages/mcp/src/tools/query-relationships.ts @@ -192,7 +192,7 @@ symbols: sourceColumn: 16 sourceEndLine: 91 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:30.881Z' + coordinatesGeneratedAt: '2026-04-23T08:34:29.077Z' - id: SYM-KibiCodeLensProvider title: KibiCodeLensProvider sourceFile: packages/vscode/src/codeLensProvider.ts @@ -209,7 +209,7 @@ symbols: sourceColumn: 13 sourceEndLine: 338 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:31.037Z' + coordinatesGeneratedAt: '2026-04-23T08:34:29.263Z' - id: SYM-mergeStaticLinks title: mergeStaticLinks sourceFile: packages/vscode/src/codeLensProvider.ts @@ -224,7 +224,7 @@ symbols: sourceColumn: 10 sourceEndLine: 214 sourceEndColumn: 3 - coordinatesGeneratedAt: '2026-04-22T07:28:31.039Z' + coordinatesGeneratedAt: '2026-04-23T08:34:29.266Z' - id: SYM-parseSymbolsManifest title: parseSymbolsManifest sourceFile: packages/vscode/src/symbolIndex.ts @@ -241,7 +241,7 @@ symbols: sourceColumn: 9 sourceEndLine: 197 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:31.040Z' + coordinatesGeneratedAt: '2026-04-23T08:34:29.266Z' - id: SYM-getKbExistenceTargets title: getKbExistenceTargets sourceFile: packages/opencode/src/file-filter.ts @@ -256,7 +256,7 @@ symbols: sourceColumn: 16 sourceEndLine: 102 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:31.192Z' + coordinatesGeneratedAt: '2026-04-23T08:34:29.453Z' - id: SYM-checkWorkspaceHealth title: checkWorkspaceHealth sourceFile: packages/opencode/src/workspace-health.ts @@ -271,7 +271,7 @@ symbols: sourceColumn: 16 sourceEndLine: 96 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:31.411Z' + coordinatesGeneratedAt: '2026-04-23T08:34:29.604Z' - id: SYM-detectPosture title: detectPosture sourceFile: packages/opencode/src/repo-posture.ts @@ -289,7 +289,7 @@ symbols: sourceColumn: 16 sourceEndLine: 241 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:31.415Z' + coordinatesGeneratedAt: '2026-04-23T08:34:29.607Z' - id: SYM-classifyRisk title: classifyRisk sourceFile: packages/opencode/src/risk-classifier.ts @@ -307,7 +307,7 @@ symbols: sourceColumn: 16 sourceEndLine: 175 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:31.572Z' + coordinatesGeneratedAt: '2026-04-23T08:34:29.749Z' - id: SYM-GuidanceCache title: GuidanceCache sourceFile: packages/opencode/src/guidance-cache.ts @@ -325,7 +325,7 @@ symbols: sourceColumn: 13 sourceEndLine: 162 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:31.768Z' + coordinatesGeneratedAt: '2026-04-23T08:34:29.885Z' - id: SYM-buildPrompt title: buildPrompt sourceFile: packages/opencode/src/prompt.ts @@ -348,7 +348,7 @@ symbols: sourceColumn: 16 sourceEndLine: 484 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:31.951Z' + coordinatesGeneratedAt: '2026-04-23T08:34:30.085Z' - id: SYM-parseRdfRelationships title: parseRdfRelationships sourceFile: packages/vscode/src/shared/rdf-parser.ts @@ -361,7 +361,7 @@ symbols: sourceColumn: 16 sourceEndLine: 67 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:31.951Z' + coordinatesGeneratedAt: '2026-04-23T08:34:30.085Z' - id: SYM-KB_RELATIONSHIP_TYPES title: KB_RELATIONSHIP_TYPES sourceFile: packages/vscode/src/shared/rdf-parser.ts @@ -374,7 +374,7 @@ symbols: sourceColumn: 13 sourceEndLine: 28 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-22T07:28:31.952Z' + coordinatesGeneratedAt: '2026-04-23T08:34:30.086Z' - id: SYM-kb-status-json title: kb_status/0 (JSON) sourceFile: packages/core/src/status.pl diff --git a/documentation/tests/TEST-opencode-kibi-briefing-v1.md b/documentation/tests/TEST-opencode-kibi-briefing-v1.md index b1f645c..4f948f5 100644 --- a/documentation/tests/TEST-opencode-kibi-briefing-v1.md +++ b/documentation/tests/TEST-opencode-kibi-briefing-v1.md @@ -1,7 +1,7 @@ --- id: TEST-opencode-kibi-briefing-v1 title: "OpenCode Kibi Briefings v1 Verification" -status: pending +status: deprecated created_at: 2026-04-20T00:00:00Z updated_at: 2026-04-20T00:00:00Z source: documentation/tests/TEST-opencode-kibi-briefing-v1.md @@ -16,6 +16,9 @@ links: target: SCEN-opencode-kibi-briefing-v1 --- + +19#YT|> **Note**: This test doc is DEPRECATED and superseded by TEST-opencode-kibi-briefing-v2. +20#YT|> Historical verification for v1 cue-driven briefings remains documented below. Automated verification for the OpenCode Kibi Briefings v1 contract includes: 1. **Sanctioned Command Policy Test**: Verify that agent-facing OpenCode guidance treats `/brief-kibi` as a sanctioned start-task command. diff --git a/documentation/tests/TEST-opencode-kibi-briefing-v2.md b/documentation/tests/TEST-opencode-kibi-briefing-v2.md new file mode 100644 index 0000000..2dca454 --- /dev/null +++ b/documentation/tests/TEST-opencode-kibi-briefing-v2.md @@ -0,0 +1,30 @@ +--- +id: TEST-opencode-kibi-briefing-v2 +title: "OpenCode Kibi Briefings v2 Verification" +status: pending +created_at: 2026-04-23T00:00:00Z +updated_at: 2026-04-23T00:00:00Z +source: documentation/tests/TEST-opencode-kibi-briefing-v2.md +priority: must +tags: + - test + - opencode + - briefing + - auto-show +links: + - type: validates + target: SCEN-opencode-kibi-briefing-v2 + - type: supersedes + target: TEST-opencode-kibi-briefing-v1 +--- + +Automated and manual verification for the OpenCode Kibi Briefings v2 contract: + +1. **Auto-Show Workflow Test**: Verify that risky code-edit contexts trigger a background fetch via `file.edited` and subsequent prompt injection without manual intervention. +2. **Deduplication Test**: Verify that identical context fingerprints within the TTL do not trigger redundant briefing fetches or duplicate prompt blocks. +3. **Toast Content Test**: Verify that toast notifications match the required copy for `ready`, `tldr`, and `unavailable` states exactly. +4. **Header and Fallback Test**: Verify that injected briefings use the required emoji header (`🧠 **Kibi briefing available**`) and that empty prompt blocks correctly fallback to TLdr summaries. +5. **Cue Suppression Test**: Verify that the manual `/brief-kibi` discovery cue is omitted ONLY when an authoritative prompt block is already rendered for the same fingerprint. +6. **Transform Text-Only Guarantee**: Verify that `experimental.chat.system.transform` remains a text-only hook and does not attempt live tool execution or rich object injection. +7. **Manual Path Preservation**: Verify that `/brief-kibi` remains functional even after an auto-briefing has been displayed. +8. **Surface Policy Compliance**: Verify that v2 documentation files are included in the `agent-surface-policy.test.ts` coverage if applicable, and that they do not contain forbidden CLI commands. diff --git a/packages/mcp/tests/tools/briefing-generate.test.ts b/packages/mcp/tests/tools/briefing-generate.test.ts index c59a580..30d710e 100644 --- a/packages/mcp/tests/tools/briefing-generate.test.ts +++ b/packages/mcp/tests/tools/briefing-generate.test.ts @@ -607,6 +607,9 @@ describe("briefing generate", () => { expect(unsupported.structuredContent.briefingState).toBe("no_briefing"); expect(unsupported.structuredContent.activationState).toBe("vendored_only"); expect(unsupported.structuredContent.promptBlock).toBe(""); + expect(unsupported.content).toEqual([ + { type: "text", text: "No briefing is available." }, + ]); expect(unsupported.structuredContent.entities).toEqual([]); expect(unsupported.structuredContent.constraints).toEqual([]); expect(unsupported.structuredContent.regressionRisks).toEqual([]); @@ -640,6 +643,9 @@ describe("briefing generate", () => { dirty: true, syncedAt: "2026-04-19T12:00:00Z", }); + expect(stale.content).toEqual([ + { type: "text", text: "No briefing is available." }, + ]); expect(stale.structuredContent.promptBlock).toBe(""); expect(stale.structuredContent.entities).toEqual([]); expect(stale.structuredContent.constraints).toEqual([]); diff --git a/packages/opencode/tests/agent-surface-policy.test.ts b/packages/opencode/tests/agent-surface-policy.test.ts index 64bdd4b..d92d24a 100644 --- a/packages/opencode/tests/agent-surface-policy.test.ts +++ b/packages/opencode/tests/agent-surface-policy.test.ts @@ -33,13 +33,16 @@ describe("agent surface policy", () => { ".github/copilot-instructions.md", "docs/prompts/llm-rules.md", "documentation/requirements/REQ-opencode-kibi-plugin-v1.md", + "documentation/requirements/REQ-opencode-kibi-briefing-v2.md", "documentation/requirements/REQ-opencode-agent-mcp-only.md", "documentation/requirements/REQ-opencode-smart-enforcement-v1.md", "documentation/scenarios/SCEN-010.md", "documentation/scenarios/SCEN-opencode-enforcement.md", "documentation/scenarios/SCEN-opencode-agent-mcp-only.md", "documentation/scenarios/SCEN-opencode-smart-enforcement.md", + "documentation/scenarios/SCEN-opencode-kibi-briefing-v2.md", "documentation/tests/TEST-opencode-kibi-plugin-v1.md", + "documentation/tests/TEST-opencode-kibi-briefing-v2.md", "documentation/tests/TEST-opencode-agent-mcp-only.md", "documentation/tests/TEST-opencode-smart-enforcement.md", "documentation/adr/ADR-019.md", @@ -60,9 +63,11 @@ describe("agent surface policy", () => { const allowedCommands = ["/init-kibi", "/brief-kibi"]; const briefingPolicyFiles = [ "documentation/requirements/REQ-opencode-kibi-plugin-v1.md", + "documentation/requirements/REQ-opencode-kibi-briefing-v2.md", "documentation/requirements/REQ-opencode-agent-mcp-only.md", "documentation/requirements/REQ-opencode-smart-enforcement-v1.md", "documentation/scenarios/SCEN-opencode-kibi-plugin-v1.md", + "documentation/scenarios/SCEN-opencode-kibi-briefing-v2.md", "documentation/scenarios/SCEN-opencode-agent-mcp-only.md", "documentation/scenarios/SCEN-opencode-smart-enforcement.md", "documentation/adr/ADR-018.md", diff --git a/packages/opencode/tests/hook-contract.test.ts b/packages/opencode/tests/hook-contract.test.ts index 93035d3..ea76035 100644 --- a/packages/opencode/tests/hook-contract.test.ts +++ b/packages/opencode/tests/hook-contract.test.ts @@ -249,6 +249,10 @@ describe("hook contract", () => { !injected.includes("experimental.chat.system.transform"), "Hook output should not expose hook internals", ); + assert.ok( + !injected.includes("kb_briefing_generate") && !injected.includes("briefingState"), + "Hook output should not embed live briefing execution or structured briefing payloads", + ); }); test("chat.params does not modify system array", async () => { From d208668699d7c7444defd557ee09e51ab6f29fd5 Mon Sep 17 00:00:00 2001 From: Piotr Franczyk Date: Thu, 23 Apr 2026 10:40:03 +0200 Subject: [PATCH 02/17] fix(docs): remove corrupted line-id markers from SCEN-opencode-kibi-briefing-v2 --- .../scenarios/SCEN-opencode-kibi-briefing-v2.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/documentation/scenarios/SCEN-opencode-kibi-briefing-v2.md b/documentation/scenarios/SCEN-opencode-kibi-briefing-v2.md index 1fb5ea4..1e51eb9 100644 --- a/documentation/scenarios/SCEN-opencode-kibi-briefing-v2.md +++ b/documentation/scenarios/SCEN-opencode-kibi-briefing-v2.md @@ -34,15 +34,13 @@ links: **THEN** it must NOT inject speculative content into the prompt **AND** it must show a toast: `"Kibi brief unavailable β€” keeping /brief-kibi manual path."` **AND** the manual discovery cue for `/brief-kibi` must be preserved in prompt guidance. -37#RB| -38#BR|**Scenario: Verification via MCP tool β€” manual check of context fingerprint via `kb_briefing_generate`** -39#MS| -40#ZJ|**GIVEN** an agent is in an OpenCode session and receives a Kibi-briefing-enabled prompt -41#XZ|**WHEN** the agent needs to verify the current context fingerprint or force a briefing refresh -42#VS|**THEN** the agent must use the `kb_briefing_generate` MCP tool instead of any direct CLI commands. -43#ZP|**AND** the tool must return the current context fingerprint and any available briefing content. -44#TW| -45#NN|**Scenario: TLdr fallback β€” empty promptBlock but non-empty tldr, fallback block shown** + +**Scenario: Verification via MCP tool β€” manual check of context fingerprint via `kb_briefing_generate`** + +**GIVEN** an agent is in an OpenCode session and receives a Kibi-briefing-enabled prompt +**WHEN** the agent needs to verify the current context fingerprint or force a briefing refresh +**THEN** the agent must use the `kb_briefing_generate` MCP tool instead of any direct CLI commands. +**AND** the tool must return the current context fingerprint and any available briefing content. **Scenario: TLdr fallback β€” empty promptBlock but non-empty tldr, fallback block shown** From bfd805bb18890e532d01ee7d479a425b6e5a9c2f Mon Sep 17 00:00:00 2001 From: Piotr Franczyk Date: Thu, 23 Apr 2026 10:45:36 +0200 Subject: [PATCH 03/17] test(opencode): add briefing intent helper matrix --- packages/opencode/src/brief-intent.ts | 173 +++++++++++++ packages/opencode/tests/brief-intent.test.ts | 256 +++++++++++++++++++ 2 files changed, 429 insertions(+) create mode 100644 packages/opencode/src/brief-intent.ts create mode 100644 packages/opencode/tests/brief-intent.test.ts diff --git a/packages/opencode/src/brief-intent.ts b/packages/opencode/src/brief-intent.ts new file mode 100644 index 0000000..99ec5ec --- /dev/null +++ b/packages/opencode/src/brief-intent.ts @@ -0,0 +1,173 @@ +// implements REQ-opencode-smart-enforcement-v1 +// Single source of truth for auto-briefing eligibility. +// No side effects, no SDK calls - pure function. + +import type { RepoPosture } from "./repo-posture.js"; +import type { RiskClass } from "./risk-classifier.js"; + +/** + * Postures considered authoritative for strict/briefing eligibility. + * Matches prompt.ts AUTHORITATIVE_POSTURES. + */ +const AUTHORITATIVE_POSTURES: readonly RepoPosture[] = [ + "root_active", + "hybrid_root_plus_vendored", +]; + +/** + * Risk classes eligible for auto-briefing. + * Only authoritative risky code edits trigger auto-briefing. + */ +const ELIGIBLE_RISK_CLASSES: readonly RiskClass[] = [ + "behavior_candidate", + "traceability_candidate", +]; + +/** + * Check if posture is authoritative. + */ +function isAuthoritativePosture(posture: RepoPosture): boolean { + return AUTHORITATIVE_POSTURES.includes(posture); +} + +/** + * Check if risk class is eligible for auto-briefing. + */ +function isEligibleRiskClass(riskClass: RiskClass): boolean { + return ELIGIBLE_RISK_CLASSES.includes(riskClass); +} + +/** + * Input parameters for computing brief intent. + * All fields are required - caller provides runtime context. + */ +export interface BriefIntentInputs { + /** Workspace root path */ + workspaceRoot: string; + /** Current branch name */ + branch: string; + /** Path to the edited file */ + editedFilePath: string; + /** Current repository posture */ + posture: RepoPosture; + /** Classified risk of the edit */ + riskClass: RiskClass; + /** Whether maintenance subsystem is degraded */ + maintenanceDegraded: boolean; + /** Function to get source-linked requirement IDs for a file */ + getSourceLinkedRequirementIds: (worktree: string, absoluteFilePath: string) => string[]; +} + +/** + * Result of computing brief intent. + * All fields are deterministically derived from inputs. + */ +export interface BriefIntentResult { + /** Whether auto-briefing is eligible */ + eligible: boolean; + /** Human-readable reason for eligibility decision */ + reason: string; + /** Stable fingerprint for cache lookups */ + fingerprint: string; + /** Source files to include in briefing */ + sourceFiles: string[]; + /** Source-linked requirement IDs (up to 3) */ + seedIds: string[]; + /** Whether to keep the manual /brief-kibi cue */ + keepManualCue: boolean; +} + +/** + * Compute auto-briefing eligibility and metadata from current plugin state. + * + * Eligibility rules (matched from prompt.ts /brief-kibi cue conditions): + * - Risk class must be behavior_candidate or traceability_candidate + * - Posture must be authoritative (root_active or hybrid_root_plus_vendored) + * - Maintenance must not be degraded + * - Must have sourceFiles or seedIds context + * + * This is a PURE function - no side effects, no SDK calls. + */ +// implements REQ-opencode-smart-enforcement-v1 +export function computeBriefIntent(inputs: BriefIntentInputs): BriefIntentResult { + const { + workspaceRoot, + branch, + editedFilePath, + posture, + riskClass, + maintenanceDegraded, + getSourceLinkedRequirementIds, + } = inputs; + + // Derive sourceFiles: default to edited file if present + const sourceFiles: string[] = editedFilePath ? [editedFilePath] : []; + + // Build fingerprint: workspace\0branch\0editedFilePath\0riskClass + // Matches guidance-cache.ts serializeKey pattern + const fingerprint = [ + workspaceRoot, + branch, + editedFilePath, + riskClass, + ].join("\0"); + + // Check eligibility conditions (mirrors prompt.ts lines 293-299) + const riskEligible = isEligibleRiskClass(riskClass); + const postureAuthorized = isAuthoritativePosture(posture); + const notDegraded = !maintenanceDegraded; + + // Get seedIds if we have context + let seedIds: string[] = []; + if (sourceFiles.length > 0 && getSourceLinkedRequirementIds) { + try { + // Convert relative path to absolute for the lookup + const absolutePath = editedFilePath.startsWith("/") + ? editedFilePath + : `${workspaceRoot}/${editedFilePath}`.replace(/\/+/g, "/"); + seedIds = getSourceLinkedRequirementIds(workspaceRoot, absolutePath).slice(0, 3); + } catch { + // Best-effort: empty on error + seedIds = []; + } + } + + // Eligibility: must have eligible risk class AND authoritative posture AND not degraded + // AND must have some context (sourceFiles OR seedIds) + const hasContext = sourceFiles.length > 0 || seedIds.length > 0; + const eligible = + riskEligible && postureAuthorized && notDegraded && hasContext; + + // Derive reason + let reason: string; + if (!riskEligible) { + reason = `Risk class '${riskClass}' is not eligible for auto-briefing`; + } else if (!postureAuthorized) { + reason = `Posture '${posture}' is not authoritative`; + } else if (maintenanceDegraded) { + reason = "Maintenance subsystem is degraded"; + } else if (!hasContext) { + reason = "No source context available (no sourceFiles or seedIds)"; + } else { + reason = `Eligible: authoritative ${riskClass} in ${posture} posture`; + } + + // keepManualCue defaults to true + // Caller will set to false only when runtime state confirms ready + const keepManualCue = true; + + return { + eligible, + reason, + fingerprint, + sourceFiles, + seedIds, + keepManualCue, + }; +} + +/** + * Type exports for consumers + */ +export type { RepoPosture } from "./repo-posture.js"; +export type { RiskClass } from "./risk-classifier.js"; \ No newline at end of file diff --git a/packages/opencode/tests/brief-intent.test.ts b/packages/opencode/tests/brief-intent.test.ts new file mode 100644 index 0000000..ace9cac --- /dev/null +++ b/packages/opencode/tests/brief-intent.test.ts @@ -0,0 +1,256 @@ +// TDD: These tests will FAIL first, then brief-intent.ts implementation will make them pass. +// implements REQ-opencode-smart-enforcement-v1 + +import { describe, it } from "node:test"; +import assert from "node:assert"; +import type { RepoPosture } from "../src/repo-posture.js"; +import type { RiskClass } from "../src/risk-classifier.js"; +import { + computeBriefIntent, + type BriefIntentInputs, + type BriefIntentResult, +} from "../src/brief-intent.js"; + +// Helper to create inputs with required defaults +function makeInputs(overrides: Partial = {}): BriefIntentInputs { + return { + workspaceRoot: "/test-workspace", + branch: "main", + editedFilePath: "src/foo.ts", + posture: "root_active", + riskClass: "behavior_candidate", + maintenanceDegraded: false, + getSourceLinkedRequirementIds: () => [], + ...overrides, + }; +} + +// Test: behavior_candidate + authoritative posture -> eligible=true with seedIds +describe("brief-intent: behavior_candidate with authoritative posture", () => { + it("returns eligible=true and populates seedIds when source-linked IDs exist", () => { + const inputs = makeInputs({ + getSourceLinkedRequirementIds: () => ["REQ-001", "REQ-002"], + }); + const result = computeBriefIntent(inputs); + assert.strictEqual(result.eligible, true, "Should be eligible for authoritative risky code"); + assert.deepStrictEqual(result.seedIds, ["REQ-001", "REQ-002"], "Should include source-linked IDs"); + assert.strictEqual(result.keepManualCue, true, "Should keep manual cue by default"); + }); +}); + +// Test: traceability_candidate + authoritative posture -> eligible=true +describe("brief-intent: traceability_candidate with authoritative posture", () => { + it("returns eligible=true for traceability_candidate", () => { + const inputs = makeInputs({ + riskClass: "traceability_candidate", + }); + const result = computeBriefIntent(inputs); + assert.strictEqual(result.eligible, true, "Should be eligible for traceability_candidate"); + }); +}); + +// Test: non-authoritative posture -> eligible=false +describe("brief-intent: non-authoritative posture", () => { + it("returns ineligible for vendored_only posture", () => { + const inputs = makeInputs({ + posture: "vendored_only", + }); + const result = computeBriefIntent(inputs); + assert.strictEqual(result.eligible, false, "Should NOT be eligible for vendored_only"); + }); + + it("returns ineligible for root_partial posture", () => { + const inputs = makeInputs({ + posture: "root_partial", + }); + const result = computeBriefIntent(inputs); + assert.strictEqual(result.eligible, false, "Should NOT be eligible for root_partial"); + }); + + it("returns ineligible for root_uninitialized posture", () => { + const inputs = makeInputs({ + posture: "root_uninitialized", + }); + const result = computeBriefIntent(inputs); + assert.strictEqual(result.eligible, false, "Should NOT be eligible for root_uninitialized"); + }); +}); + +// Test: maintenance_degraded -> eligible=false +describe("brief-intent: maintenance degraded", () => { + it("returns ineligible when maintenanceDegraded=true", () => { + const inputs = makeInputs({ + maintenanceDegraded: true, + }); + const result = computeBriefIntent(inputs); + assert.strictEqual(result.eligible, false, "Should NOT be eligible when maintenance degraded"); + }); +}); + +// Test: safe_docs_only -> eligible=false +describe("brief-intent: safe_docs_only", () => { + it("returns ineligible for safe_docs_only", () => { + const inputs = makeInputs({ + riskClass: "safe_docs_only", + }); + const result = computeBriefIntent(inputs); + assert.strictEqual(result.eligible, false, "Should NOT be eligible for safe_docs_only"); + }); +}); + +// Test: safe_test_only -> eligible=false +describe("brief-intent: safe_test_only", () => { + it("returns ineligible for safe_test_only", () => { + const inputs = makeInputs({ + riskClass: "safe_test_only", + }); + const result = computeBriefIntent(inputs); + assert.strictEqual(result.eligible, false, "Should NOT be eligible for safe_test_only"); + }); +}); + +// Test: manual_kb_edit -> eligible=false +describe("brief-intent: manual_kb_edit", () => { + it("returns ineligible for manual_kb_edit", () => { + const inputs = makeInputs({ + riskClass: "manual_kb_edit", + }); + const result = computeBriefIntent(inputs); + assert.strictEqual(result.eligible, false, "Should NOT be eligible for manual_kb_edit"); + }); +}); + +// Test: eligible class but empty source-linked IDs and no sourceFiles -> eligible=false +describe("brief-intent: empty source-linked IDs", () => { + it("returns ineligible when no sourceFiles and no seedIds", () => { + const inputs = makeInputs({ + editedFilePath: "", // Empty path means no source file + getSourceLinkedRequirementIds: () => [], + }); + const result = computeBriefIntent(inputs); + assert.strictEqual(result.eligible, false, "Should NOT be eligible without source context"); + }); + + it("returns eligible with sourceFiles even when seedIds are empty", () => { + const inputs = makeInputs({ + editedFilePath: "src/foo.ts", + getSourceLinkedRequirementIds: () => [], + }); + const result = computeBriefIntent(inputs); + assert.strictEqual(result.eligible, true, "Should be eligible with sourceFiles"); + }); +}); + +// Test: same-fingerprint repeated invocations produce identical results (determinism) +describe("brief-intent: determinism", () => { + it("produces identical results for same inputs", () => { + const inputs = makeInputs({ + workspaceRoot: "/ws", + branch: "feature", + editedFilePath: "src/bar.ts", + riskClass: "behavior_candidate", + posture: "root_active", + }); + const result1 = computeBriefIntent(inputs); + const result2 = computeBriefIntent(inputs); + assert.strictEqual(result1.fingerprint, result2.fingerprint, "Fingerprint should be deterministic"); + assert.strictEqual(result1.eligible, result2.eligible, "Eligibility should be deterministic"); + assert.strictEqual(result1.reason, result2.reason, "Reason should be deterministic"); + assert.deepStrictEqual(result1.sourceFiles, result2.sourceFiles, "sourceFiles should be deterministic"); + assert.deepStrictEqual(result1.seedIds, result2.seedIds, "seedIds should be deterministic"); + }); +}); + +// Test: req_policy_candidate -> eligible=false +describe("brief-intent: req_policy_candidate", () => { + it("returns ineligible for req_policy_candidate", () => { + const inputs = makeInputs({ + riskClass: "req_policy_candidate", + }); + const result = computeBriefIntent(inputs); + assert.strictEqual(result.eligible, false, "Should NOT be eligible for req_policy_candidate"); + }); +}); + +// Test: kb_doc_structural -> eligible=false +describe("brief-intent: kb_doc_structural", () => { + it("returns ineligible for kb_doc_structural", () => { + const inputs = makeInputs({ + riskClass: "kb_doc_structural", + }); + const result = computeBriefIntent(inputs); + assert.strictEqual(result.eligible, false, "Should NOT be eligible for kb_doc_structural"); + }); +}); + +// Test: fingerprint construction +describe("brief-intent: fingerprint", () => { + it("includes workspaceRoot, branch, editedFilePath, and riskClass", () => { + const inputs = makeInputs({ + workspaceRoot: "/my-workspace", + branch: "develop", + editedFilePath: "src/utils.ts", + riskClass: "traceability_candidate", + }); + const result = computeBriefIntent(inputs); + assert.ok( + result.fingerprint.includes("/my-workspace") && + result.fingerprint.includes("develop") && + result.fingerprint.includes("src/utils.ts") && + result.fingerprint.includes("traceability_candidate"), + "Fingerprint should contain all key components", + ); + }); +}); + +// Test: sourceFiles defaults to edited file +describe("brief-intent: sourceFiles defaults", () => { + it("defaults sourceFiles to edited file path", () => { + const inputs = makeInputs({ + editedFilePath: "src/auth/login.ts", + }); + const result = computeBriefIntent(inputs); + assert.deepStrictEqual(result.sourceFiles, ["src/auth/login.ts"], "Should default to edited file"); + }); + + it("returns empty sourceFiles when editedFilePath is empty", () => { + const inputs = makeInputs({ + editedFilePath: "", + }); + const result = computeBriefIntent(inputs); + assert.deepStrictEqual(result.sourceFiles, [], "Should be empty when no file"); + }); +}); + +// Test: hybrid_root_plus_vendored is authoritative +describe("brief-intent: hybrid_root_plus_vendored authoritative", () => { + it("returns eligible=true for hybrid_root_plus_vendored with behavior_candidate", () => { + const inputs = makeInputs({ + posture: "hybrid_root_plus_vendored", + riskClass: "behavior_candidate", + }); + const result = computeBriefIntent(inputs); + assert.strictEqual(result.eligible, true, "Should be eligible for hybrid_root_plus_vendored"); + }); + + it("returns eligible=true for hybrid_root_plus_vendored with traceability_candidate", () => { + const inputs = makeInputs({ + posture: "hybrid_root_plus_vendored", + riskClass: "traceability_candidate", + }); + const result = computeBriefIntent(inputs); + assert.strictEqual(result.eligible, true, "Should be eligible for hybrid_root_plus_vendored"); + }); +}); + +// Test: up to 3 seedIds +describe("brief-intent: seedIds limit", () => { + it("includes up to 3 source-linked requirement IDs", () => { + const inputs = makeInputs({ + getSourceLinkedRequirementIds: () => ["REQ-001", "REQ-002", "REQ-003", "REQ-004"], + }); + const result = computeBriefIntent(inputs); + assert.strictEqual(result.seedIds.length, 3, "Should limit to 3 seedIds"); + assert.deepStrictEqual(result.seedIds, ["REQ-001", "REQ-002", "REQ-003"]); + }); +}); \ No newline at end of file From c097db623111282748fb088f9a8fdef1a3659bb1 Mon Sep 17 00:00:00 2001 From: Piotr Franczyk Date: Thu, 23 Apr 2026 11:16:59 +0200 Subject: [PATCH 04/17] refactor(opencode): improve brief-intent helper with ReadonlySet and seed-ID sourcing --- packages/opencode/src/brief-intent.ts | 216 ++++----- packages/opencode/tests/brief-intent.test.ts | 462 +++++++++++-------- 2 files changed, 339 insertions(+), 339 deletions(-) diff --git a/packages/opencode/src/brief-intent.ts b/packages/opencode/src/brief-intent.ts index 99ec5ec..d7d3700 100644 --- a/packages/opencode/src/brief-intent.ts +++ b/packages/opencode/src/brief-intent.ts @@ -1,173 +1,117 @@ -// implements REQ-opencode-smart-enforcement-v1 -// Single source of truth for auto-briefing eligibility. -// No side effects, no SDK calls - pure function. +// implements REQ-opencode-kibi-briefing-v2, REQ-opencode-smart-enforcement-v1 import type { RepoPosture } from "./repo-posture.js"; import type { RiskClass } from "./risk-classifier.js"; +import { getSourceLinkedRequirementIds } from "./source-linked-guidance.js"; -/** - * Postures considered authoritative for strict/briefing eligibility. - * Matches prompt.ts AUTHORITATIVE_POSTURES. - */ -const AUTHORITATIVE_POSTURES: readonly RepoPosture[] = [ - "root_active", - "hybrid_root_plus_vendored", -]; - -/** - * Risk classes eligible for auto-briefing. - * Only authoritative risky code edits trigger auto-briefing. - */ -const ELIGIBLE_RISK_CLASSES: readonly RiskClass[] = [ +const ELIGIBLE_RISK_CLASSES: ReadonlySet = new Set([ "behavior_candidate", "traceability_candidate", -]; +]); -/** - * Check if posture is authoritative. - */ -function isAuthoritativePosture(posture: RepoPosture): boolean { - return AUTHORITATIVE_POSTURES.includes(posture); -} - -/** - * Check if risk class is eligible for auto-briefing. - */ -function isEligibleRiskClass(riskClass: RiskClass): boolean { - return ELIGIBLE_RISK_CLASSES.includes(riskClass); -} +const STRICT_ELIGIBLE_POSTURES: ReadonlySet = new Set([ + "root_active", + "hybrid_root_plus_vendored", +]); -/** - * Input parameters for computing brief intent. - * All fields are required - caller provides runtime context. - */ -export interface BriefIntentInputs { - /** Workspace root path */ - workspaceRoot: string; - /** Current branch name */ - branch: string; - /** Path to the edited file */ - editedFilePath: string; - /** Current repository posture */ - posture: RepoPosture; - /** Classified risk of the edit */ +export interface BriefIntentParams { riskClass: RiskClass; - /** Whether maintenance subsystem is degraded */ + posture: RepoPosture; maintenanceDegraded: boolean; - /** Function to get source-linked requirement IDs for a file */ - getSourceLinkedRequirementIds: (worktree: string, absoluteFilePath: string) => string[]; + workspaceRoot: string; + branch: string; + editedFilePath: string | undefined; + seedIds?: string[]; } -/** - * Result of computing brief intent. - * All fields are deterministically derived from inputs. - */ export interface BriefIntentResult { - /** Whether auto-briefing is eligible */ eligible: boolean; - /** Human-readable reason for eligibility decision */ reason: string; - /** Stable fingerprint for cache lookups */ fingerprint: string; - /** Source files to include in briefing */ sourceFiles: string[]; - /** Source-linked requirement IDs (up to 3) */ seedIds: string[]; - /** Whether to keep the manual /brief-kibi cue */ keepManualCue: boolean; } -/** - * Compute auto-briefing eligibility and metadata from current plugin state. - * - * Eligibility rules (matched from prompt.ts /brief-kibi cue conditions): - * - Risk class must be behavior_candidate or traceability_candidate - * - Posture must be authoritative (root_active or hybrid_root_plus_vendored) - * - Maintenance must not be degraded - * - Must have sourceFiles or seedIds context - * - * This is a PURE function - no side effects, no SDK calls. - */ -// implements REQ-opencode-smart-enforcement-v1 -export function computeBriefIntent(inputs: BriefIntentInputs): BriefIntentResult { - const { - workspaceRoot, - branch, - editedFilePath, - posture, - riskClass, - maintenanceDegraded, - getSourceLinkedRequirementIds, - } = inputs; +function hasEditedFilePath(editedFilePath: string | undefined): editedFilePath is string { + return typeof editedFilePath === "string" && editedFilePath.length > 0; +} - // Derive sourceFiles: default to edited file if present - const sourceFiles: string[] = editedFilePath ? [editedFilePath] : []; +function deriveSeedIds(params: BriefIntentParams): string[] { + if (!hasEditedFilePath(params.editedFilePath)) { + return []; + } - // Build fingerprint: workspace\0branch\0editedFilePath\0riskClass - // Matches guidance-cache.ts serializeKey pattern - const fingerprint = [ - workspaceRoot, - branch, - editedFilePath, - riskClass, - ].join("\0"); + if (params.seedIds !== undefined) { + return params.seedIds.slice(0, 3); + } - // Check eligibility conditions (mirrors prompt.ts lines 293-299) - const riskEligible = isEligibleRiskClass(riskClass); - const postureAuthorized = isAuthoritativePosture(posture); - const notDegraded = !maintenanceDegraded; + return getSourceLinkedRequirementIds( + params.workspaceRoot, + params.editedFilePath, + ).slice(0, 3); +} - // Get seedIds if we have context - let seedIds: string[] = []; - if (sourceFiles.length > 0 && getSourceLinkedRequirementIds) { - try { - // Convert relative path to absolute for the lookup - const absolutePath = editedFilePath.startsWith("/") - ? editedFilePath - : `${workspaceRoot}/${editedFilePath}`.replace(/\/+/g, "/"); - seedIds = getSourceLinkedRequirementIds(workspaceRoot, absolutePath).slice(0, 3); - } catch { - // Best-effort: empty on error - seedIds = []; - } +// implements REQ-opencode-kibi-briefing-v2, REQ-opencode-smart-enforcement-v1 +export function deriveBriefIntent( + params: BriefIntentParams, +): BriefIntentResult { + const fingerprint = `brief:${params.workspaceRoot}\0${params.branch}\0${params.editedFilePath ?? ""}\0${params.riskClass}`; + const sourceFiles = hasEditedFilePath(params.editedFilePath) + ? [params.editedFilePath] + : []; + const seedIds = deriveSeedIds(params); + + if (!hasEditedFilePath(params.editedFilePath)) { + return { + eligible: false, + reason: "Ineligible: edited file path is missing", + fingerprint, + sourceFiles, + seedIds, + keepManualCue: true, + }; } - // Eligibility: must have eligible risk class AND authoritative posture AND not degraded - // AND must have some context (sourceFiles OR seedIds) - const hasContext = sourceFiles.length > 0 || seedIds.length > 0; - const eligible = - riskEligible && postureAuthorized && notDegraded && hasContext; + if (!ELIGIBLE_RISK_CLASSES.has(params.riskClass)) { + return { + eligible: false, + reason: `Ineligible: riskClass ${params.riskClass} is not auto-brief eligible`, + fingerprint, + sourceFiles, + seedIds, + keepManualCue: true, + }; + } - // Derive reason - let reason: string; - if (!riskEligible) { - reason = `Risk class '${riskClass}' is not eligible for auto-briefing`; - } else if (!postureAuthorized) { - reason = `Posture '${posture}' is not authoritative`; - } else if (maintenanceDegraded) { - reason = "Maintenance subsystem is degraded"; - } else if (!hasContext) { - reason = "No source context available (no sourceFiles or seedIds)"; - } else { - reason = `Eligible: authoritative ${riskClass} in ${posture} posture`; + if (!STRICT_ELIGIBLE_POSTURES.has(params.posture)) { + return { + eligible: false, + reason: `Ineligible: posture ${params.posture} is not authoritative`, + fingerprint, + sourceFiles, + seedIds, + keepManualCue: true, + }; } - // keepManualCue defaults to true - // Caller will set to false only when runtime state confirms ready - const keepManualCue = true; + if (params.maintenanceDegraded) { + return { + eligible: false, + reason: "Ineligible: maintenance is degraded", + fingerprint, + sourceFiles, + seedIds, + keepManualCue: true, + }; + } return { - eligible, - reason, + eligible: true, + reason: "Eligible for auto-briefing", fingerprint, sourceFiles, seedIds, - keepManualCue, + keepManualCue: true, }; } - -/** - * Type exports for consumers - */ -export type { RepoPosture } from "./repo-posture.js"; -export type { RiskClass } from "./risk-classifier.js"; \ No newline at end of file diff --git a/packages/opencode/tests/brief-intent.test.ts b/packages/opencode/tests/brief-intent.test.ts index ace9cac..71257e5 100644 --- a/packages/opencode/tests/brief-intent.test.ts +++ b/packages/opencode/tests/brief-intent.test.ts @@ -1,256 +1,312 @@ -// TDD: These tests will FAIL first, then brief-intent.ts implementation will make them pass. -// implements REQ-opencode-smart-enforcement-v1 - -import { describe, it } from "node:test"; -import assert from "node:assert"; -import type { RepoPosture } from "../src/repo-posture.js"; -import type { RiskClass } from "../src/risk-classifier.js"; -import { - computeBriefIntent, - type BriefIntentInputs, - type BriefIntentResult, -} from "../src/brief-intent.js"; - -// Helper to create inputs with required defaults -function makeInputs(overrides: Partial = {}): BriefIntentInputs { +/// +// implements REQ-opencode-kibi-briefing-v2 + +import { afterEach, beforeEach, describe, test } from "bun:test"; +import { strict as assert } from "node:assert"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { RepoPosture } from "../src/repo-posture"; +import type { RiskClass } from "../src/risk-classifier"; + +type BriefIntentParams = { + riskClass: RiskClass; + posture: RepoPosture; + maintenanceDegraded: boolean; + workspaceRoot: string; + branch: string; + editedFilePath: string | undefined; + seedIds?: string[]; +}; + +type BriefIntentResult = { + eligible: boolean; + reason: string; + fingerprint: string; + sourceFiles: string[]; + seedIds: string[]; + keepManualCue: boolean; +}; + +type BriefIntentModule = { + deriveBriefIntent?: (params: BriefIntentParams) => BriefIntentResult; +}; + +function makeParams(overrides: Partial = {}): BriefIntentParams { return { - workspaceRoot: "/test-workspace", - branch: "main", - editedFilePath: "src/foo.ts", - posture: "root_active", riskClass: "behavior_candidate", + posture: "root_active", maintenanceDegraded: false, - getSourceLinkedRequirementIds: () => [], + workspaceRoot: "/workspace", + branch: "feature/task-3", + editedFilePath: "/workspace/src/foo.ts", ...overrides, }; } -// Test: behavior_candidate + authoritative posture -> eligible=true with seedIds -describe("brief-intent: behavior_candidate with authoritative posture", () => { - it("returns eligible=true and populates seedIds when source-linked IDs exist", () => { - const inputs = makeInputs({ - getSourceLinkedRequirementIds: () => ["REQ-001", "REQ-002"], - }); - const result = computeBriefIntent(inputs); - assert.strictEqual(result.eligible, true, "Should be eligible for authoritative risky code"); - assert.deepStrictEqual(result.seedIds, ["REQ-001", "REQ-002"], "Should include source-linked IDs"); - assert.strictEqual(result.keepManualCue, true, "Should keep manual cue by default"); +async function loadModule(): Promise { + try { + return (await import("../src/brief-intent.js")) as unknown as BriefIntentModule; + } catch { + return {}; + } +} + +async function derive( + overrides: Partial = {}, +): Promise { + const mod = await loadModule(); + const deriveBriefIntent = mod.deriveBriefIntent; + assert.equal( + typeof deriveBriefIntent, + "function", + "Expected brief-intent.ts to export deriveBriefIntent(params)", + ); + if (typeof deriveBriefIntent !== "function") { + throw new Error("deriveBriefIntent export missing"); + } + return deriveBriefIntent(makeParams(overrides)); +} + +describe("deriveBriefIntent", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kibi-brief-intent-")); }); -}); -// Test: traceability_candidate + authoritative posture -> eligible=true -describe("brief-intent: traceability_candidate with authoritative posture", () => { - it("returns eligible=true for traceability_candidate", () => { - const inputs = makeInputs({ - riskClass: "traceability_candidate", - }); - const result = computeBriefIntent(inputs); - assert.strictEqual(result.eligible, true, "Should be eligible for traceability_candidate"); + afterEach(() => { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch {} }); -}); -// Test: non-authoritative posture -> eligible=false -describe("brief-intent: non-authoritative posture", () => { - it("returns ineligible for vendored_only posture", () => { - const inputs = makeInputs({ - posture: "vendored_only", - }); - const result = computeBriefIntent(inputs); - assert.strictEqual(result.eligible, false, "Should NOT be eligible for vendored_only"); + function writeSymbolsYaml( + entries: Array<{ + id: string; + sourceFile: string; + links?: string[]; + relationships?: Array<{ type: string; target: string }>; + }>, + ) { + const documentationDir = path.join(tmpDir, "documentation"); + fs.mkdirSync(documentationDir, { recursive: true }); + const yaml = entries + .map((entry) => { + let content = ` - id: ${entry.id}\n sourceFile: ${entry.sourceFile}\n`; + if (entry.links?.length) { + content += " links:\n"; + for (const link of entry.links) { + content += ` - ${link}\n`; + } + } + if (entry.relationships?.length) { + content += " relationships:\n"; + for (const relationship of entry.relationships) { + content += ` - type: ${relationship.type}\n target: ${relationship.target}\n`; + } + } + return content; + }) + .join("\n"); + fs.writeFileSync(path.join(documentationDir, "symbols.yaml"), yaml); + } + + test("returns eligible for behavior_candidate in root_active posture", async () => { + const result = await derive(); + + assert.equal(result.eligible, true); + assert.equal(result.reason, "Eligible for auto-briefing"); + assert.equal(result.keepManualCue, true); + assert.deepEqual(result.sourceFiles, ["/workspace/src/foo.ts"]); + assert.deepEqual(result.seedIds, []); }); - it("returns ineligible for root_partial posture", () => { - const inputs = makeInputs({ - posture: "root_partial", + test("returns eligible for traceability_candidate in hybrid_root_plus_vendored posture", async () => { + const result = await derive({ + riskClass: "traceability_candidate", + posture: "hybrid_root_plus_vendored", }); - const result = computeBriefIntent(inputs); - assert.strictEqual(result.eligible, false, "Should NOT be eligible for root_partial"); + + assert.equal(result.eligible, true); + assert.equal(result.reason, "Eligible for auto-briefing"); }); - it("returns ineligible for root_uninitialized posture", () => { - const inputs = makeInputs({ - posture: "root_uninitialized", - }); - const result = computeBriefIntent(inputs); - assert.strictEqual(result.eligible, false, "Should NOT be eligible for root_uninitialized"); + test("returns ineligible for vendored_only posture", async () => { + const result = await derive({ posture: "vendored_only" }); + + assert.equal(result.eligible, false); + assert.ok(result.reason.includes("posture")); + assert.ok(result.reason.includes("vendored_only")); }); -}); -// Test: maintenance_degraded -> eligible=false -describe("brief-intent: maintenance degraded", () => { - it("returns ineligible when maintenanceDegraded=true", () => { - const inputs = makeInputs({ - maintenanceDegraded: true, - }); - const result = computeBriefIntent(inputs); - assert.strictEqual(result.eligible, false, "Should NOT be eligible when maintenance degraded"); + test("returns ineligible for root_partial posture", async () => { + const result = await derive({ posture: "root_partial" }); + + assert.equal(result.eligible, false); + assert.ok(result.reason.includes("root_partial")); }); -}); -// Test: safe_docs_only -> eligible=false -describe("brief-intent: safe_docs_only", () => { - it("returns ineligible for safe_docs_only", () => { - const inputs = makeInputs({ - riskClass: "safe_docs_only", - }); - const result = computeBriefIntent(inputs); - assert.strictEqual(result.eligible, false, "Should NOT be eligible for safe_docs_only"); + test("returns ineligible for root_uninitialized posture", async () => { + const result = await derive({ posture: "root_uninitialized" }); + + assert.equal(result.eligible, false); + assert.ok(result.reason.includes("root_uninitialized")); }); -}); -// Test: safe_test_only -> eligible=false -describe("brief-intent: safe_test_only", () => { - it("returns ineligible for safe_test_only", () => { - const inputs = makeInputs({ - riskClass: "safe_test_only", - }); - const result = computeBriefIntent(inputs); - assert.strictEqual(result.eligible, false, "Should NOT be eligible for safe_test_only"); + test("returns ineligible when maintenance is degraded", async () => { + const result = await derive({ maintenanceDegraded: true }); + + assert.equal(result.eligible, false); + assert.ok(result.reason.includes("degraded")); }); -}); -// Test: manual_kb_edit -> eligible=false -describe("brief-intent: manual_kb_edit", () => { - it("returns ineligible for manual_kb_edit", () => { - const inputs = makeInputs({ - riskClass: "manual_kb_edit", - }); - const result = computeBriefIntent(inputs); - assert.strictEqual(result.eligible, false, "Should NOT be eligible for manual_kb_edit"); + test("returns ineligible for safe_docs_only", async () => { + const result = await derive({ riskClass: "safe_docs_only" }); + + assert.equal(result.eligible, false); + assert.ok(result.reason.includes("safe_docs_only")); }); -}); -// Test: eligible class but empty source-linked IDs and no sourceFiles -> eligible=false -describe("brief-intent: empty source-linked IDs", () => { - it("returns ineligible when no sourceFiles and no seedIds", () => { - const inputs = makeInputs({ - editedFilePath: "", // Empty path means no source file - getSourceLinkedRequirementIds: () => [], - }); - const result = computeBriefIntent(inputs); - assert.strictEqual(result.eligible, false, "Should NOT be eligible without source context"); + test("returns ineligible for safe_test_only", async () => { + const result = await derive({ riskClass: "safe_test_only" }); + + assert.equal(result.eligible, false); + assert.ok(result.reason.includes("safe_test_only")); }); - it("returns eligible with sourceFiles even when seedIds are empty", () => { - const inputs = makeInputs({ - editedFilePath: "src/foo.ts", - getSourceLinkedRequirementIds: () => [], - }); - const result = computeBriefIntent(inputs); - assert.strictEqual(result.eligible, true, "Should be eligible with sourceFiles"); + test("returns ineligible for req_policy_candidate", async () => { + const result = await derive({ riskClass: "req_policy_candidate" }); + + assert.equal(result.eligible, false); + assert.ok(result.reason.includes("req_policy_candidate")); }); -}); -// Test: same-fingerprint repeated invocations produce identical results (determinism) -describe("brief-intent: determinism", () => { - it("produces identical results for same inputs", () => { - const inputs = makeInputs({ - workspaceRoot: "/ws", - branch: "feature", - editedFilePath: "src/bar.ts", - riskClass: "behavior_candidate", - posture: "root_active", - }); - const result1 = computeBriefIntent(inputs); - const result2 = computeBriefIntent(inputs); - assert.strictEqual(result1.fingerprint, result2.fingerprint, "Fingerprint should be deterministic"); - assert.strictEqual(result1.eligible, result2.eligible, "Eligibility should be deterministic"); - assert.strictEqual(result1.reason, result2.reason, "Reason should be deterministic"); - assert.deepStrictEqual(result1.sourceFiles, result2.sourceFiles, "sourceFiles should be deterministic"); - assert.deepStrictEqual(result1.seedIds, result2.seedIds, "seedIds should be deterministic"); + test("returns ineligible for kb_doc_structural", async () => { + const result = await derive({ riskClass: "kb_doc_structural" }); + + assert.equal(result.eligible, false); + assert.ok(result.reason.includes("kb_doc_structural")); }); -}); -// Test: req_policy_candidate -> eligible=false -describe("brief-intent: req_policy_candidate", () => { - it("returns ineligible for req_policy_candidate", () => { - const inputs = makeInputs({ - riskClass: "req_policy_candidate", - }); - const result = computeBriefIntent(inputs); - assert.strictEqual(result.eligible, false, "Should NOT be eligible for req_policy_candidate"); + test("returns ineligible for manual_kb_edit", async () => { + const result = await derive({ riskClass: "manual_kb_edit" }); + + assert.equal(result.eligible, false); + assert.ok(result.reason.includes("manual_kb_edit")); }); -}); -// Test: kb_doc_structural -> eligible=false -describe("brief-intent: kb_doc_structural", () => { - it("returns ineligible for kb_doc_structural", () => { - const inputs = makeInputs({ - riskClass: "kb_doc_structural", - }); - const result = computeBriefIntent(inputs); - assert.strictEqual(result.eligible, false, "Should NOT be eligible for kb_doc_structural"); + test("returns ineligible when editedFilePath is undefined", async () => { + const result = await derive({ editedFilePath: undefined, seedIds: ["REQ-001"] }); + + assert.equal(result.eligible, false); + assert.ok(result.reason.includes("edited file")); + assert.deepEqual(result.sourceFiles, []); + assert.deepEqual(result.seedIds, []); + }); + + test("returns ineligible when editedFilePath is empty", async () => { + const result = await derive({ editedFilePath: "" }); + + assert.equal(result.eligible, false); + assert.ok(result.reason.includes("edited file")); + assert.deepEqual(result.sourceFiles, []); + assert.deepEqual(result.seedIds, []); }); -}); -// Test: fingerprint construction -describe("brief-intent: fingerprint", () => { - it("includes workspaceRoot, branch, editedFilePath, and riskClass", () => { - const inputs = makeInputs({ - workspaceRoot: "/my-workspace", - branch: "develop", - editedFilePath: "src/utils.ts", + test("produces identical fingerprint for the same params twice", async () => { + const first = await derive({ + workspaceRoot: "/repo", + branch: "feature/brief", + editedFilePath: "/repo/packages/opencode/src/prompt.ts", riskClass: "traceability_candidate", }); - const result = computeBriefIntent(inputs); - assert.ok( - result.fingerprint.includes("/my-workspace") && - result.fingerprint.includes("develop") && - result.fingerprint.includes("src/utils.ts") && - result.fingerprint.includes("traceability_candidate"), - "Fingerprint should contain all key components", - ); + const second = await derive({ + workspaceRoot: "/repo", + branch: "feature/brief", + editedFilePath: "/repo/packages/opencode/src/prompt.ts", + riskClass: "traceability_candidate", + }); + + assert.equal(first.fingerprint, second.fingerprint); }); -}); -// Test: sourceFiles defaults to edited file -describe("brief-intent: sourceFiles defaults", () => { - it("defaults sourceFiles to edited file path", () => { - const inputs = makeInputs({ - editedFilePath: "src/auth/login.ts", + test("uses the exact fingerprint serialization pattern", async () => { + const result = await derive({ + workspaceRoot: "/repo", + branch: "feature/brief", + editedFilePath: "/repo/src/feature.ts", + riskClass: "behavior_candidate", }); - const result = computeBriefIntent(inputs); - assert.deepStrictEqual(result.sourceFiles, ["src/auth/login.ts"], "Should default to edited file"); + + assert.equal( + result.fingerprint, + "brief:/repo\0feature/brief\0/repo/src/feature.ts\0behavior_candidate", + ); }); - it("returns empty sourceFiles when editedFilePath is empty", () => { - const inputs = makeInputs({ - editedFilePath: "", - }); - const result = computeBriefIntent(inputs); - assert.deepStrictEqual(result.sourceFiles, [], "Should be empty when no file"); + test("keeps keepManualCue true even when result is ineligible", async () => { + const result = await derive({ posture: "vendored_only" }); + + assert.equal(result.keepManualCue, true); }); -}); -// Test: hybrid_root_plus_vendored is authoritative -describe("brief-intent: hybrid_root_plus_vendored authoritative", () => { - it("returns eligible=true for hybrid_root_plus_vendored with behavior_candidate", () => { - const inputs = makeInputs({ - posture: "hybrid_root_plus_vendored", - riskClass: "behavior_candidate", + test("uses pre-fetched seedIds directly and truncates to three", async () => { + writeSymbolsYaml([ + { + id: "SYM-foo", + sourceFile: "src/foo.ts", + relationships: [{ type: "implements", target: "REQ-from-disk" }], + }, + ]); + + const result = await derive({ + workspaceRoot: tmpDir, + editedFilePath: path.join(tmpDir, "src/foo.ts"), + seedIds: ["REQ-001", "REQ-002", "REQ-003", "REQ-004"], }); - const result = computeBriefIntent(inputs); - assert.strictEqual(result.eligible, true, "Should be eligible for hybrid_root_plus_vendored"); + + assert.deepEqual(result.seedIds, ["REQ-001", "REQ-002", "REQ-003"]); }); - it("returns eligible=true for hybrid_root_plus_vendored with traceability_candidate", () => { - const inputs = makeInputs({ - posture: "hybrid_root_plus_vendored", - riskClass: "traceability_candidate", + test("derives seedIds from source-linked guidance when not pre-fetched", async () => { + writeSymbolsYaml([ + { + id: "SYM-foo", + sourceFile: "src/foo.ts", + relationships: [ + { type: "implements", target: "REQ-001" }, + { type: "implements", target: "REQ-002" }, + { type: "implements", target: "REQ-003" }, + { type: "implements", target: "REQ-004" }, + ], + }, + ]); + + const result = await derive({ + workspaceRoot: tmpDir, + editedFilePath: path.join(tmpDir, "src/foo.ts"), }); - const result = computeBriefIntent(inputs); - assert.strictEqual(result.eligible, true, "Should be eligible for hybrid_root_plus_vendored"); + + assert.deepEqual(result.seedIds, ["REQ-001", "REQ-002", "REQ-003"]); }); -}); -// Test: up to 3 seedIds -describe("brief-intent: seedIds limit", () => { - it("includes up to 3 source-linked requirement IDs", () => { - const inputs = makeInputs({ - getSourceLinkedRequirementIds: () => ["REQ-001", "REQ-002", "REQ-003", "REQ-004"], + test("returns eligible when source-linked guidance finds no requirement IDs", async () => { + writeSymbolsYaml([ + { + id: "SYM-other", + sourceFile: "src/other.ts", + relationships: [{ type: "implements", target: "REQ-999" }], + }, + ]); + + const result = await derive({ + workspaceRoot: tmpDir, + editedFilePath: path.join(tmpDir, "src/foo.ts"), }); - const result = computeBriefIntent(inputs); - assert.strictEqual(result.seedIds.length, 3, "Should limit to 3 seedIds"); - assert.deepStrictEqual(result.seedIds, ["REQ-001", "REQ-002", "REQ-003"]); + + assert.equal(result.eligible, true); + assert.deepEqual(result.seedIds, []); + assert.deepEqual(result.sourceFiles, [path.join(tmpDir, "src/foo.ts")]); }); -}); \ No newline at end of file +}); From 384557b3bd43f565c3e5923a61429f364108fdee Mon Sep 17 00:00:00 2001 From: Piotr Franczyk Date: Thu, 23 Apr 2026 11:36:11 +0200 Subject: [PATCH 05/17] refactor(opencode): centralize toast capability handling --- packages/opencode/src/startup-notifier.ts | 59 +++++---------------- packages/opencode/src/toast.ts | 62 +++++++++++++++++++++++ 2 files changed, 74 insertions(+), 47 deletions(-) create mode 100644 packages/opencode/src/toast.ts diff --git a/packages/opencode/src/startup-notifier.ts b/packages/opencode/src/startup-notifier.ts index d63f8d9..edb6064 100644 --- a/packages/opencode/src/startup-notifier.ts +++ b/packages/opencode/src/startup-notifier.ts @@ -1,22 +1,14 @@ -export type ToastPayload = { - variant?: "info" | "success" | "warning" | "error"; - title?: string; - message: string; - duration?: number; -}; +import { + hasLegacyToast, + hasShowToast, + sendToast, + type ToastCapableClient, + type ToastPayload, +} from "./toast.js"; -export type StartupNotifierClient = { - tui?: { - showToast?: (payload: { - body: { - title?: string; - message: string; - variant?: "info" | "success" | "warning" | "error"; - duration?: number; - }; - }) => void | Promise; - toast?: (payload: ToastPayload) => void | Promise; - }; +export type { ToastPayload } from "./toast.js"; + +export type StartupNotifierClient = ToastCapableClient & { app: { log: (payload: Record) => Promise; }; @@ -28,33 +20,6 @@ export type StartupNotifierConfig = { directory?: string; }; -function hasShowToast( - client: StartupNotifierClient, -): client is StartupNotifierClient & { - tui: { - showToast: (payload: { - body: { - title?: string; - message: string; - variant?: "info" | "success" | "warning" | "error"; - duration?: number; - }; - }) => void | Promise; - }; -} { - return typeof client.tui?.showToast === "function"; -} - -function hasLegacyToast( - client: StartupNotifierClient, -): client is StartupNotifierClient & { - tui: { - toast: (payload: ToastPayload) => void | Promise; - }; -} { - return typeof client.tui?.toast === "function"; -} - // implements REQ-opencode-kibi-plugin-v1 export function notifyStartup( client: StartupNotifierClient, @@ -70,7 +35,7 @@ export function notifyStartup( if (!cfg.suppressToast) { if (hasShowToast(client)) { - void Promise.resolve(client.tui.showToast({ body: toastPayload })) + void Promise.resolve(sendToast(client, toastPayload)) .then( (result) => void Promise.resolve( @@ -107,7 +72,7 @@ export function notifyStartup( }); }); } else if (hasLegacyToast(client)) { - void Promise.resolve(client.tui.toast(toastPayload)).catch((err) => { + void Promise.resolve(sendToast(client, toastPayload)).catch((err) => { console.error("[kibi-opencode] startup toast failed:", err); }); } diff --git a/packages/opencode/src/toast.ts b/packages/opencode/src/toast.ts new file mode 100644 index 0000000..285d7c6 --- /dev/null +++ b/packages/opencode/src/toast.ts @@ -0,0 +1,62 @@ +export type ToastPayload = { + variant?: "info" | "success" | "warning" | "error"; + title?: string; + message: string; + duration?: number; +}; + +type ShowToastPayload = { + body: ToastPayload; +}; + +type ShowToast = (payload: ShowToastPayload) => void | Promise; +type LegacyToast = (payload: ToastPayload) => void | Promise; + +type ToastUi = { + showToast?: ShowToast; + toast?: LegacyToast; +}; + +export type ToastCapableClient = { + tui?: ToastUi; +}; + +type ClientWithShowToast = ToastCapableClient & { + tui: ToastUi & { + showToast: ShowToast; + }; +}; + +type ClientWithLegacyToast = ToastCapableClient & { + tui: ToastUi & { + toast: LegacyToast; + }; +}; + +// implements REQ-opencode-kibi-plugin-v1 +export function hasShowToast( + client: ToastCapableClient, +): client is ClientWithShowToast { + return typeof client.tui?.showToast === "function"; +} + +// implements REQ-opencode-kibi-plugin-v1 +export function hasLegacyToast( + client: ToastCapableClient, +): client is ClientWithLegacyToast { + return typeof client.tui?.toast === "function"; +} + +// implements REQ-opencode-kibi-plugin-v1 +export function sendToast( + client: ToastCapableClient, + payload: ToastPayload, +): void | Promise { + if (hasShowToast(client)) { + return client.tui.showToast({ body: payload }); + } + + if (hasLegacyToast(client)) { + return client.tui.toast(payload); + } +} From 9862a8ec0540fafe83dde132d16cbc4bb2625c53 Mon Sep 17 00:00:00 2001 From: Piotr Franczyk Date: Thu, 23 Apr 2026 12:30:50 +0200 Subject: [PATCH 06/17] feat(opencode): add auto-brief runtime helper --- packages/opencode/src/briefing-runtime.ts | 405 +++++++++++++ .../tests/briefing-auto-render.test.ts | 543 ++++++++++++++++++ 2 files changed, 948 insertions(+) create mode 100644 packages/opencode/src/briefing-runtime.ts create mode 100644 packages/opencode/tests/briefing-auto-render.test.ts diff --git a/packages/opencode/src/briefing-runtime.ts b/packages/opencode/src/briefing-runtime.ts new file mode 100644 index 0000000..8bea267 --- /dev/null +++ b/packages/opencode/src/briefing-runtime.ts @@ -0,0 +1,405 @@ +// implements REQ-opencode-kibi-briefing-v2 + +import type { BriefIntentResult } from "./brief-intent.js"; + +export type BriefingWorkspaceCtx = { + workspaceRoot: string; + branch: string; + directory?: string; + workspace?: string; + ttlMs?: number; +}; + +export type BriefingCitation = { + id: string; + type?: string; + title?: string; + source?: string; + textRef?: string; +}; + +export type BriefingRuntimeResult = { + state: "ready" | "tldr_fallback" | "no_briefing"; + promptBlock: string; + tldr: string; + citations: BriefingCitation[]; + showManualCue: boolean; + toastMessage: string; +}; + +type PromptTextPart = { + type: "text"; + text: string; +}; + +type SessionCreateParams = { + directory: string; + title: string; +}; + +type SessionPromptParams = { + sessionID: string; + tools: { + [key: string]: boolean; + }; + format: { + type: "json_schema"; + schema: Record; + }; + parts: PromptTextPart[]; +}; + +type SessionApi = { + create: (parameters: SessionCreateParams) => Promise; + prompt: (parameters: SessionPromptParams) => Promise; +}; + +type PromptPayload = { + briefingState?: unknown; + tldr?: unknown; + promptBlock?: unknown; + citations?: unknown; +}; + +const DEFAULT_TTL_MS = 300_000; +const WORKER_TITLE = "Kibi Auto Brief Worker"; +const READY_TOAST = "Kibi brief ready β€” summary added to guidance."; +const TLDR_FALLBACK_TOAST = + "Kibi brief summary added β€” use /brief-kibi for full details."; +const UNAVAILABLE_TOAST = + "Kibi brief unavailable β€” keeping /brief-kibi manual path."; +const PROMPT_INSTRUCTION = + "Call only kb_briefing_generate once with the provided sourceFiles and seedIds. If briefingState is ready, copy only cited fields. If briefingState is no_briefing, return empty promptBlock/citations and keep manual cue availability. Never invent claims."; +const PROMPT_FORMAT: SessionPromptParams["format"] = { + type: "json_schema", + schema: { + type: "object", + properties: { + briefingState: { type: "string" }, + tldr: { type: "string" }, + promptBlock: { type: "string" }, + citations: { type: "array", items: { type: "object" } }, + activationState: { type: "string" }, + confidence: { type: "string" }, + freshness: { type: "string" }, + }, + required: ["briefingState"], + }, +}; + +const workerSessionIds = new Map(); +const workerSessionPromises = new Map>(); +const resultCache = new Map< + string, + { + result: BriefingRuntimeResult; + timestamp: number; + } +>(); +const inFlightResults = new Map>(); + +function noBriefingResult(): BriefingRuntimeResult { + return { + state: "no_briefing", + promptBlock: "", + tldr: "", + citations: [], + showManualCue: true, + toastMessage: UNAVAILABLE_TOAST, + }; +} + +function workspaceSessionKey(workspaceCtx: BriefingWorkspaceCtx): string { + return `${workspaceCtx.workspaceRoot}\0${workspaceCtx.branch}`; +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null + ? (value as Record) + : null; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function sanitizePromptBlock(value: unknown): string { + return asString(value).trim(); +} + +function sanitizeCitations(value: unknown): BriefingCitation[] { + if (!Array.isArray(value)) { + return []; + } + + const citations: BriefingCitation[] = []; + for (const item of value) { + const record = asRecord(item); + if (!record) { + continue; + } + + const id = asString(record.id).trim(); + if (!id) { + continue; + } + + const citation: BriefingCitation = { id }; + const type = asString(record.type).trim(); + const title = asString(record.title).trim(); + const source = asString(record.source).trim(); + const textRef = asString(record.textRef).trim(); + + if (type) { + citation.type = type; + } + if (title) { + citation.title = title; + } + if (source) { + citation.source = source; + } + if (textRef) { + citation.textRef = textRef; + } + + citations.push(citation); + } + + return citations; +} + +function hasBriefingState(value: unknown): value is PromptPayload { + const record = asRecord(value); + return record !== null && "briefingState" in record; +} + +function extractParts(response: unknown): unknown[] { + const root = asRecord(response); + if (!root) { + return []; + } + + const data = asRecord(root.data); + const parts = data?.parts ?? root.parts; + + return Array.isArray(parts) ? parts : []; +} + +function parsePromptPayload(response: unknown): PromptPayload | null { + const parts = extractParts(response); + + for (let index = parts.length - 1; index >= 0; index -= 1) { + const part = asRecord(parts[index]); + if (!part || part.type !== "text") { + continue; + } + + const text = asString(part.text); + if (!text) { + continue; + } + + try { + const parsed = JSON.parse(text) as unknown; + if (hasBriefingState(parsed)) { + return parsed; + } + } catch { + // Ignore malformed text parts and continue searching from the end. + } + } + + return null; +} + +function normalizeResult(payload: PromptPayload | null): BriefingRuntimeResult { + if (!payload) { + return noBriefingResult(); + } + + const briefingState = asString(payload.briefingState).trim(); + const tldr = asString(payload.tldr).trim(); + const promptBlock = sanitizePromptBlock(payload.promptBlock); + + if (briefingState === "ready" && promptBlock) { + return { + state: "ready", + promptBlock, + tldr, + citations: sanitizeCitations(payload.citations), + showManualCue: false, + toastMessage: READY_TOAST, + }; + } + + if (briefingState === "ready" && tldr) { + return { + state: "tldr_fallback", + promptBlock: `- ${tldr}\n- Full details: run /brief-kibi.`, + tldr, + citations: [], + showManualCue: true, + toastMessage: TLDR_FALLBACK_TOAST, + }; + } + + return noBriefingResult(); +} + +function getSessionApi(client: unknown): SessionApi | null { + const root = asRecord(client); + const session = asRecord(root?.session); + + if (!session) { + return null; + } + + const create = session.create; + const prompt = session.prompt; + if (typeof create !== "function" || typeof prompt !== "function") { + return null; + } + + return { + create: create as SessionApi["create"], + prompt: prompt as SessionApi["prompt"], + }; +} + +function extractSessionId(response: unknown): string | null { + const root = asRecord(response); + if (!root) { + return null; + } + + const directId = asString(root.id).trim(); + if (directId) { + return directId; + } + + const data = asRecord(root.data); + const dataId = asString(data?.id).trim(); + + return dataId || null; +} + +async function getWorkerSessionId( + sessionApi: SessionApi, + workspaceCtx: BriefingWorkspaceCtx, +): Promise { + const key = workspaceSessionKey(workspaceCtx); + const existing = workerSessionIds.get(key); + if (existing) { + return existing; + } + + const pending = workerSessionPromises.get(key); + if (pending) { + return pending; + } + + const promise = (async () => { + const response = await sessionApi.create({ + directory: workspaceCtx.workspaceRoot, + title: WORKER_TITLE, + }); + const sessionId = extractSessionId(response); + if (!sessionId) { + throw new Error("Failed to resolve worker session ID"); + } + + workerSessionIds.set(key, sessionId); + return sessionId; + })().finally(() => { + workerSessionPromises.delete(key); + }); + + workerSessionPromises.set(key, promise); + return promise; +} + +async function loadBriefingResult( + sessionApi: SessionApi, + workspaceCtx: BriefingWorkspaceCtx, + intentResult: BriefIntentResult, +): Promise { + const sessionID = await getWorkerSessionId(sessionApi, workspaceCtx); + const response = await sessionApi.prompt({ + sessionID, + tools: { kb_briefing_generate: true }, + format: PROMPT_FORMAT, + parts: [ + { + type: "text", + text: PROMPT_INSTRUCTION, + }, + { + type: "text", + text: JSON.stringify({ + sourceFiles: intentResult.sourceFiles, + seedIds: intentResult.seedIds, + }), + }, + ], + }); + + return normalizeResult(parsePromptPayload(response)); +} + +// implements REQ-opencode-kibi-briefing-v2 +export async function fetchBriefingResult( + client: unknown, + workspaceCtx: BriefingWorkspaceCtx, + intentResult: BriefIntentResult, +): Promise { + const ttlMs = workspaceCtx.ttlMs ?? DEFAULT_TTL_MS; + const cached = resultCache.get(intentResult.fingerprint); + const now = Date.now(); + + if (cached && now - cached.timestamp <= ttlMs) { + return cached.result; + } + + if (cached) { + resultCache.delete(intentResult.fingerprint); + } + + const pending = inFlightResults.get(intentResult.fingerprint); + if (pending) { + return pending; + } + + const sessionApi = getSessionApi(client); + if (!sessionApi || !intentResult.eligible) { + const result = noBriefingResult(); + resultCache.set(intentResult.fingerprint, { + result, + timestamp: now, + }); + return result; + } + + const promise = (async () => { + try { + const result = await loadBriefingResult(sessionApi, workspaceCtx, intentResult); + resultCache.set(intentResult.fingerprint, { + result, + timestamp: Date.now(), + }); + return result; + } catch { + const result = noBriefingResult(); + resultCache.set(intentResult.fingerprint, { + result, + timestamp: Date.now(), + }); + return result; + } + })().finally(() => { + inFlightResults.delete(intentResult.fingerprint); + }); + + inFlightResults.set(intentResult.fingerprint, promise); + return promise; +} diff --git a/packages/opencode/tests/briefing-auto-render.test.ts b/packages/opencode/tests/briefing-auto-render.test.ts new file mode 100644 index 0000000..1f2992d --- /dev/null +++ b/packages/opencode/tests/briefing-auto-render.test.ts @@ -0,0 +1,543 @@ +/// +// implements REQ-opencode-kibi-briefing-v2 + +import { afterEach, describe, test } from "bun:test"; +import { strict as assert } from "node:assert"; +import type { BriefIntentResult } from "../src/brief-intent"; + +const READY_TOAST = "Kibi brief ready β€” summary added to guidance."; +const TLDR_FALLBACK_TOAST = + "Kibi brief summary added β€” use /brief-kibi for full details."; +const UNAVAILABLE_TOAST = + "Kibi brief unavailable β€” keeping /brief-kibi manual path."; +const WORKER_PROMPT_INSTRUCTION = + "Call only kb_briefing_generate once with the provided sourceFiles and seedIds. If briefingState is ready, copy only cited fields. If briefingState is no_briefing, return empty promptBlock/citations and keep manual cue availability. Never invent claims."; + +type BriefingWorkspaceCtx = { + workspaceRoot: string; + branch: string; + directory?: string; + workspace?: string; + ttlMs?: number; +}; + +type BriefingCitation = { + id: string; + type?: string; + title?: string; + source?: string; + textRef?: string; +}; + +type BriefingRuntimeResult = { + state: "ready" | "tldr_fallback" | "no_briefing"; + promptBlock: string; + tldr: string; + citations: BriefingCitation[]; + showManualCue: boolean; + toastMessage: string; +}; + +type BriefingRuntimeModule = { + fetchBriefingResult?: ( + client: unknown, + workspaceCtx: BriefingWorkspaceCtx, + intentResult: BriefIntentResult, + ) => Promise; +}; + +type CreateParameters = { + directory?: string; + workspace?: string; + title?: string; +}; + +type PromptParameters = { + sessionID: string; + directory?: string; + workspace?: string; + tools?: Record; + format?: Record; + parts?: Array<{ type: "text"; text: string }>; +}; + +const originalDateNow = Date.now; +let workspaceCounter = 0; + +afterEach(() => { + Date.now = originalDateNow; +}); + +async function loadModule(): Promise { + try { + const modulePath = "../src/" + "briefing-runtime.js"; + return (await import(modulePath)) as BriefingRuntimeModule; + } catch { + return {}; + } +} + +async function fetchRuntimeResult( + client: unknown, + workspaceCtx: BriefingWorkspaceCtx, + intentResult: BriefIntentResult, +): Promise { + const mod = await loadModule(); + assert.equal( + typeof mod.fetchBriefingResult, + "function", + "Expected briefing-runtime.ts to export fetchBriefingResult(client, workspaceCtx, intentResult)", + ); + if (typeof mod.fetchBriefingResult !== "function") { + throw new Error("fetchBriefingResult export missing"); + } + return mod.fetchBriefingResult(client, workspaceCtx, intentResult); +} + +async function waitFor( + predicate: () => boolean, + attempts = 10, +): Promise { + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (predicate()) { + return; + } + await Promise.resolve(); + } + + assert.fail("Timed out waiting for async condition"); +} + +function makeWorkspaceCtx( + overrides: Partial = {}, +): BriefingWorkspaceCtx { + workspaceCounter += 1; + return { + workspaceRoot: `/workspace-${workspaceCounter}`, + branch: `feature-${workspaceCounter}`, + ttlMs: 300_000, + ...overrides, + }; +} + +function makeIntent( + workspaceCtx: BriefingWorkspaceCtx, + overrides: Partial = {}, +): BriefIntentResult { + const editedFilePath = + overrides.sourceFiles?.[0] ?? `${workspaceCtx.workspaceRoot}/src/edited.ts`; + + return { + eligible: true, + reason: "Eligible for auto-briefing", + fingerprint: + overrides.fingerprint ?? + `brief:${workspaceCtx.workspaceRoot}\0${workspaceCtx.branch}\0${editedFilePath}\0behavior_candidate`, + sourceFiles: overrides.sourceFiles ?? [editedFilePath], + seedIds: overrides.seedIds ?? ["REQ-001"], + keepManualCue: overrides.keepManualCue ?? true, + ...overrides, + }; +} + +function promptResponseFromJson(value: unknown): unknown { + return { + data: { + info: { + id: "message-1", + role: "assistant", + }, + parts: [ + { + type: "text", + text: JSON.stringify(value), + }, + ], + }, + }; +} + +function promptResponseFromText(text: string): unknown { + return { + data: { + info: { + id: "message-1", + role: "assistant", + }, + parts: [ + { + type: "text", + text, + }, + ], + }, + }; +} + +function createClientStub(options: { + createResult?: unknown; + createError?: Error; + promptResults?: unknown[]; + promptError?: Error; + promptImpl?: (parameters: PromptParameters) => Promise; +} = {}) { + const createCalls: CreateParameters[] = []; + const promptCalls: PromptParameters[] = []; + const showToastCalls: unknown[] = []; + let promptCallIndex = 0; + + const client = { + session: { + create: async (parameters?: CreateParameters) => { + createCalls.push(parameters ?? {}); + if (options.createError) { + throw options.createError; + } + return options.createResult ?? { + data: { + id: "session-1", + title: parameters?.title ?? "Kibi Auto Brief Worker", + }, + }; + }, + prompt: async (parameters: PromptParameters) => { + promptCalls.push(parameters); + if (options.promptImpl) { + return options.promptImpl(parameters); + } + if (options.promptError) { + throw options.promptError; + } + + const result = + options.promptResults?.[promptCallIndex] ?? + options.promptResults?.[options.promptResults.length - 1] ?? + promptResponseFromJson({ briefingState: "no_briefing" }); + promptCallIndex += 1; + return result; + }, + }, + tui: { + showToast: async (payload: unknown) => { + showToastCalls.push(payload); + return true; + }, + }, + }; + + return { + client, + createCalls, + promptCalls, + showToastCalls, + }; +} + +describe("fetchBriefingResult", () => { + test("returns ready state for non-empty promptBlock without sending toast directly", async () => { + const workspaceCtx = makeWorkspaceCtx(); + const intentResult = makeIntent(workspaceCtx, { + seedIds: ["REQ-001", "SCEN-001"], + sourceFiles: [`${workspaceCtx.workspaceRoot}/src/feature.ts`], + }); + const citations = [ + { + id: "REQ-001", + type: "req", + title: "Requirement 001", + source: "documentation/requirements/REQ-001.md", + textRef: "REQ-001#L1", + }, + ]; + const { client, createCalls, promptCalls, showToastCalls } = createClientStub({ + promptResults: [ + promptResponseFromJson({ + briefingState: "ready", + tldr: "Requirement and scenario context are available.", + promptBlock: "\n- REQ-001: Respect the documented invariant.\n- SCEN-001: Preserve the canonical flow.\n", + citations, + }), + ], + }); + + const result = await fetchRuntimeResult(client, workspaceCtx, intentResult); + + assert.deepEqual(result, { + state: "ready", + promptBlock: + "- REQ-001: Respect the documented invariant.\n- SCEN-001: Preserve the canonical flow.", + tldr: "Requirement and scenario context are available.", + citations, + showManualCue: false, + toastMessage: READY_TOAST, + }); + assert.equal(showToastCalls.length, 0, "runtime helper must not send toasts"); + assert.equal(createCalls.length, 1); + assert.deepEqual(createCalls[0], { + directory: workspaceCtx.workspaceRoot, + title: "Kibi Auto Brief Worker", + }); + assert.equal(promptCalls.length, 1); + assert.equal(promptCalls[0]?.sessionID, "session-1"); + assert.deepEqual(promptCalls[0]?.tools, { kb_briefing_generate: true }); + assert.equal("model" in (promptCalls[0] ?? {}), false); + assert.deepEqual(promptCalls[0]?.parts, [ + { + type: "text", + text: WORKER_PROMPT_INSTRUCTION, + }, + { + type: "text", + text: JSON.stringify({ + sourceFiles: intentResult.sourceFiles, + seedIds: intentResult.seedIds, + }), + }, + ]); + assert.equal(promptCalls[0]?.format?.type, "json_schema"); + assert.ok( + typeof promptCalls[0]?.format?.schema === "object" && + promptCalls[0]?.format?.schema !== null, + "expected prompt call to request json_schema output", + ); + }); + + test("returns tldr_fallback when promptBlock is empty but tldr is present", async () => { + const workspaceCtx = makeWorkspaceCtx(); + const intentResult = makeIntent(workspaceCtx); + const { client } = createClientStub({ + promptResults: [ + promptResponseFromJson({ + briefingState: "ready", + tldr: "Linked requirements were found.", + promptBlock: " ", + citations: [{ id: "REQ-001", type: "req", title: "Requirement 001" }], + }), + ], + }); + + const result = await fetchRuntimeResult(client, workspaceCtx, intentResult); + + assert.deepEqual(result, { + state: "tldr_fallback", + promptBlock: + "- Linked requirements were found.\n- Full details: run /brief-kibi.", + tldr: "Linked requirements were found.", + citations: [], + showManualCue: true, + toastMessage: TLDR_FALLBACK_TOAST, + }); + }); + + test("returns no_briefing for an explicit no_briefing response without fabricating prompt content", async () => { + const workspaceCtx = makeWorkspaceCtx(); + const intentResult = makeIntent(workspaceCtx); + const { client } = createClientStub({ + promptResults: [ + promptResponseFromJson({ + briefingState: "no_briefing", + tldr: "This text must not be surfaced.", + promptBlock: "- fabricated", + citations: [{ id: "REQ-001", type: "req", title: "Requirement 001" }], + }), + ], + }); + + const result = await fetchRuntimeResult(client, workspaceCtx, intentResult); + + assert.deepEqual(result, { + state: "no_briefing", + promptBlock: "", + tldr: "", + citations: [], + showManualCue: true, + toastMessage: UNAVAILABLE_TOAST, + }); + }); + + test("returns no_briefing for malformed JSON that does not contain briefingState", async () => { + const workspaceCtx = makeWorkspaceCtx(); + const intentResult = makeIntent(workspaceCtx); + const { client } = createClientStub({ + promptResults: [promptResponseFromText('{"tldr":"Partial content only"}')], + }); + + const result = await fetchRuntimeResult(client, workspaceCtx, intentResult); + + assert.deepEqual(result, { + state: "no_briefing", + promptBlock: "", + tldr: "", + citations: [], + showManualCue: true, + toastMessage: UNAVAILABLE_TOAST, + }); + }); + + test("returns no_briefing when session.create throws", async () => { + const workspaceCtx = makeWorkspaceCtx(); + const intentResult = makeIntent(workspaceCtx); + const { client, createCalls, promptCalls } = createClientStub({ + createError: new Error("session create failed"), + }); + + const result = await fetchRuntimeResult(client, workspaceCtx, intentResult); + + assert.equal(createCalls.length, 1); + assert.equal(promptCalls.length, 0); + assert.deepEqual(result, { + state: "no_briefing", + promptBlock: "", + tldr: "", + citations: [], + showManualCue: true, + toastMessage: UNAVAILABLE_TOAST, + }); + }); + + test("returns no_briefing when session.prompt throws", async () => { + const workspaceCtx = makeWorkspaceCtx(); + const intentResult = makeIntent(workspaceCtx); + const { client, createCalls, promptCalls } = createClientStub({ + promptError: new Error("session prompt failed"), + }); + + const result = await fetchRuntimeResult(client, workspaceCtx, intentResult); + + assert.equal(createCalls.length, 1); + assert.equal(promptCalls.length, 1); + assert.deepEqual(result, { + state: "no_briefing", + promptBlock: "", + tldr: "", + citations: [], + showManualCue: true, + toastMessage: UNAVAILABLE_TOAST, + }); + }); + + test("caches the same fingerprint within TTL and only prompts once", async () => { + const workspaceCtx = makeWorkspaceCtx(); + const intentResult = makeIntent(workspaceCtx); + const { client, createCalls, promptCalls } = createClientStub({ + promptResults: [ + promptResponseFromJson({ + briefingState: "ready", + tldr: "Cached briefing.", + promptBlock: "- Cached bullet", + citations: [], + }), + ], + }); + + const first = await fetchRuntimeResult(client, workspaceCtx, intentResult); + const second = await fetchRuntimeResult(client, workspaceCtx, intentResult); + + assert.deepEqual(second, first); + assert.equal(createCalls.length, 1); + assert.equal(promptCalls.length, 1); + }); + + test("deduplicates concurrent in-flight requests for the same fingerprint", async () => { + const workspaceCtx = makeWorkspaceCtx(); + const intentResult = makeIntent(workspaceCtx); + let resolvePrompt: ((value: unknown) => void) | undefined; + const promptGate = new Promise((resolve) => { + resolvePrompt = resolve; + }); + const { client, createCalls, promptCalls } = createClientStub({ + promptImpl: async () => promptGate, + }); + + const firstPromise = fetchRuntimeResult(client, workspaceCtx, intentResult); + const secondPromise = fetchRuntimeResult(client, workspaceCtx, intentResult); + await waitFor(() => createCalls.length === 1 && promptCalls.length === 1); + + assert.equal(createCalls.length, 1); + assert.equal(promptCalls.length, 1); + resolvePrompt?.( + promptResponseFromJson({ + briefingState: "ready", + tldr: "Concurrent briefing.", + promptBlock: "- Concurrent bullet", + citations: [], + }), + ); + + const [first, second] = await Promise.all([firstPromise, secondPromise]); + + assert.deepEqual(second, first); + assert.equal(promptCalls.length, 1); + }); + + test("expires cached results after the TTL window and prompts again", async () => { + const workspaceCtx = makeWorkspaceCtx({ ttlMs: 10 }); + const intentResult = makeIntent(workspaceCtx); + let now = 1_000; + Date.now = () => now; + const { client, createCalls, promptCalls } = createClientStub({ + promptResults: [ + promptResponseFromJson({ + briefingState: "ready", + tldr: "First briefing.", + promptBlock: "- First bullet", + citations: [], + }), + promptResponseFromJson({ + briefingState: "ready", + tldr: "Second briefing.", + promptBlock: "- Second bullet", + citations: [], + }), + ], + }); + + const first = await fetchRuntimeResult(client, workspaceCtx, intentResult); + now += 25; + const second = await fetchRuntimeResult(client, workspaceCtx, intentResult); + + assert.equal(createCalls.length, 1, "worker session should be reused"); + assert.equal(promptCalls.length, 2, "cache should miss after TTL expiry"); + assert.equal(first.promptBlock, "- First bullet"); + assert.equal(second.promptBlock, "- Second bullet"); + }); + + test("uses separate prompt calls for different fingerprints while reusing the worker session", async () => { + const workspaceCtx = makeWorkspaceCtx(); + const firstIntent = makeIntent(workspaceCtx, { + fingerprint: `brief:${workspaceCtx.workspaceRoot}\0${workspaceCtx.branch}\0${workspaceCtx.workspaceRoot}/src/a.ts\0behavior_candidate`, + sourceFiles: [`${workspaceCtx.workspaceRoot}/src/a.ts`], + seedIds: ["REQ-001"], + }); + const secondIntent = makeIntent(workspaceCtx, { + fingerprint: `brief:${workspaceCtx.workspaceRoot}\0${workspaceCtx.branch}\0${workspaceCtx.workspaceRoot}/src/b.ts\0behavior_candidate`, + sourceFiles: [`${workspaceCtx.workspaceRoot}/src/b.ts`], + seedIds: ["REQ-002"], + }); + const { client, createCalls, promptCalls } = createClientStub({ + promptResults: [ + promptResponseFromJson({ + briefingState: "ready", + tldr: "First fingerprint.", + promptBlock: "- First fingerprint bullet", + citations: [], + }), + promptResponseFromJson({ + briefingState: "ready", + tldr: "Second fingerprint.", + promptBlock: "- Second fingerprint bullet", + citations: [], + }), + ], + }); + + const first = await fetchRuntimeResult(client, workspaceCtx, firstIntent); + const second = await fetchRuntimeResult(client, workspaceCtx, secondIntent); + + assert.equal(createCalls.length, 1); + assert.equal(promptCalls.length, 2); + assert.equal(first.promptBlock, "- First fingerprint bullet"); + assert.equal(second.promptBlock, "- Second fingerprint bullet"); + assert.notEqual(firstIntent.fingerprint, secondIntent.fingerprint); + }); +}); From 05e1cd79221558d534c1ab84c52b78e105ee1186 Mon Sep 17 00:00:00 2001 From: Piotr Franczyk Date: Thu, 23 Apr 2026 13:11:12 +0200 Subject: [PATCH 07/17] feat(opencode): wire auto-brief into file.edited event and system-transform hook - Integrate computeBriefIntent into file.edited handler to assess eligible risk classes - Store BriefingRuntimeResult in autoBriefResults map keyed by fingerprint - Send toast via sendToast when fetchBriefingResult resolves - Pass autoBriefResult into buildPrompt context for prompt block rendering - Add PromptContext.autoBriefResult field - Add computeBriefIntent traceability link to REQ-opencode-kibi-briefing-v2 - Add 496 lines of integration tests covering all eligibility/ineligibility paths --- packages/opencode/src/brief-intent.ts | 24 ++ packages/opencode/src/index.ts | 58 +++ packages/opencode/src/prompt.ts | 3 + packages/opencode/tests/index.test.ts | 496 ++++++++++++++++++++++++++ 4 files changed, 581 insertions(+) diff --git a/packages/opencode/src/brief-intent.ts b/packages/opencode/src/brief-intent.ts index d7d3700..2aa00b7 100644 --- a/packages/opencode/src/brief-intent.ts +++ b/packages/opencode/src/brief-intent.ts @@ -33,6 +33,16 @@ export interface BriefIntentResult { keepManualCue: boolean; } +export interface BriefIntentInputs { + riskClass: RiskClass; + posture: RepoPosture; + maintenanceDegraded: boolean; + worktreeRoot: string; + branch: string; + editedFile: string | undefined; + seedIds?: string[]; +} + function hasEditedFilePath(editedFilePath: string | undefined): editedFilePath is string { return typeof editedFilePath === "string" && editedFilePath.length > 0; } @@ -115,3 +125,17 @@ export function deriveBriefIntent( keepManualCue: true, }; } + +export function computeBriefIntent( // implements REQ-opencode-kibi-briefing-v2 + inputs: BriefIntentInputs, +): BriefIntentResult { + return deriveBriefIntent({ + riskClass: inputs.riskClass, + posture: inputs.posture, + maintenanceDegraded: inputs.maintenanceDegraded, + workspaceRoot: inputs.worktreeRoot, + branch: inputs.branch, + editedFilePath: inputs.editedFile, + ...(inputs.seedIds !== undefined ? { seedIds: inputs.seedIds } : {}), + }); +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index e3a105b..dee39bc 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1,4 +1,10 @@ import * as path from "node:path"; +import { computeBriefIntent } from "./brief-intent.js"; +import { + fetchBriefingResult, + type BriefingRuntimeResult, + type BriefingWorkspaceCtx, +} from "./briefing-runtime.js"; import { type CommentAnalysisResult, analyzeCodeFile, @@ -13,6 +19,7 @@ import { type RiskClass, classifyRisk } from "./risk-classifier.js"; import { type WarningCategory, getSessionTracker } from "./session-tracker.js"; import { notifyStartup } from "./startup-notifier.js"; import { runPluginStartup } from "./plugin-startup.js"; +import { sendToast } from "./toast.js"; // implements REQ-opencode-smart-enforcement-v1, REQ-opencode-kibi-plugin-v1 @@ -31,6 +38,7 @@ function deriveFileBucket(kind: PathKind): string { export interface PluginInput { worktree: string; directory: string; + workspace?: string; project?: unknown; serverUrl?: unknown; $?: unknown; @@ -156,7 +164,9 @@ const kibiOpencodePlugin: Plugin = async ( let hasRecentKbEdit = false; let recentCommentSuggestion: CommentAnalysisResult | null = null; const seenFingerprints = new Set(); // For deduplication + const autoBriefResults = new Map(); let lastRiskClass: RiskClass | null = null; + let lastEditedFilePath: string | null = null; let degradedWarnedOnce = false; hooks.event = async ({ event }) => { @@ -206,6 +216,7 @@ const kibiOpencodePlugin: Plugin = async ( ? "traceability_candidate" : riskClass; lastRiskClass = effectiveRiskClass; + lastEditedFilePath = filePath; logger.info("smart-enforcement.risk", { event: "smart_enforcement_risk", @@ -495,6 +506,36 @@ const kibiOpencodePlugin: Plugin = async ( } else { recentCommentSuggestion = null; } + + const intentResult = computeBriefIntent({ + riskClass: effectiveRiskClass, + posture: posture.state, + maintenanceDegraded: getMaintenanceDegraded(), + editedFile: filePath, + worktreeRoot: input.worktree, + branch: currentBranch, + }); + + if ( + intentResult.eligible && + input.client && + !getMaintenanceDegraded() && + (posture.state === "root_active" || + posture.state === "hybrid_root_plus_vendored") + ) { + const client = input.client; + const workspaceCtx: BriefingWorkspaceCtx = { + workspaceRoot: input.worktree, + branch: currentBranch, + directory: input.directory, + ...(input.workspace !== undefined ? { workspace: input.workspace } : {}), + }; + + void fetchBriefingResult(client, workspaceCtx, intentResult).then((result) => { + autoBriefResults.set(intentResult.fingerprint, result); + void sendToast(client, { message: result.toastMessage }); + }); + } } return; @@ -515,6 +556,22 @@ const kibiOpencodePlugin: Plugin = async ( maintenanceDegraded && cfg.guidance.smartEnforcement.degradedMode === "warn-once" && !degradedWarnedOnce; + const autoBriefResult = (() => { + if (lastRiskClass == null || lastEditedFilePath == null) { + return undefined; + } + + const intentResult = computeBriefIntent({ + riskClass: lastRiskClass, + posture: posture.state, + maintenanceDegraded, + editedFile: lastEditedFilePath, + worktreeRoot: input.worktree, + branch: currentBranch, + }); + + return autoBriefResults.get(intentResult.fingerprint); + })(); // Build only the guidance block and append it; existing entries are preserved const guidance = buildPrompt({ @@ -530,6 +587,7 @@ const kibiOpencodePlugin: Plugin = async ( maintenanceDegraded, degradedMode: cfg.guidance.smartEnforcement.degradedMode, showDegradedAdvisory, + ...(autoBriefResult !== undefined ? { autoBriefResult } : {}), ...(lastRiskClass != null ? { riskClass: lastRiskClass } : {}), }); diff --git a/packages/opencode/src/prompt.ts b/packages/opencode/src/prompt.ts index 9052c88..042b97b 100644 --- a/packages/opencode/src/prompt.ts +++ b/packages/opencode/src/prompt.ts @@ -1,5 +1,6 @@ import * as path from "node:path"; import type { CommentAnalysisResult } from "./comment-analysis.js"; +import type { BriefingRuntimeResult } from "./briefing-runtime.js"; // implements REQ-opencode-smart-enforcement-v1, REQ-opencode-kibi-plugin-v1, REQ-opencode-agent-mcp-only import type { KibiConfig } from "./config.js"; import { isPluginEnabled } from "./config.js"; @@ -92,6 +93,8 @@ export interface PromptContext { degradedMode?: "warn-once" | "structured-only"; /** Whether to show the degraded advisory block this invocation */ showDegradedAdvisory?: boolean; + /** Stored auto-brief runtime result for the current fingerprint */ + autoBriefResult?: BriefingRuntimeResult; } // ── Guidance blocks by risk class ────────────────────────────────────── diff --git a/packages/opencode/tests/index.test.ts b/packages/opencode/tests/index.test.ts index 04d165f..6fe47aa 100644 --- a/packages/opencode/tests/index.test.ts +++ b/packages/opencode/tests/index.test.ts @@ -6,16 +6,21 @@ import { beforeEach, describe, it, + mock, + spyOn, } from "bun:test"; import { strict as assert } from "node:assert"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import kibiOpencodePlugin from "../src/index"; +import * as briefingRuntimeModule from "../src/briefing-runtime"; import * as logger from "../src/logger"; +import * as promptModule from "../src/prompt"; import type { PluginInput } from "../src/index"; import { runPluginStartup } from "../src/plugin-startup"; import { getSessionTracker, resetSessionTracker } from "../src/session-tracker"; +import type { BriefingRuntimeResult } from "../src/briefing-runtime"; // implements REQ-opencode-kibi-plugin-v1 @@ -60,6 +65,8 @@ describe.serial("index kibiOpencodePlugin", () => { resetSessionTracker(); logger.resetClient(); startupNotifyGlobals.__kibi_test_schedule_startup_notify = undefined; + mock.restore(); + mock.clearAllMocks(); }); describe("plugin setup and config disabled", () => { @@ -3148,6 +3155,495 @@ import datetime }); }); + describe("auto brief event integration", () => { + const READY_TOAST = "Kibi brief ready β€” summary added to guidance."; + let freshPluginCounter = 0; + + type AutoBriefPromptPart = { + type: "text"; + text: string; + }; + + type AutoBriefSessionCreateParams = { + directory?: string; + title?: string; + }; + + type AutoBriefSessionPromptParams = { + sessionID: string; + tools?: Record; + format?: Record; + parts?: AutoBriefPromptPart[]; + }; + + type AutoBriefClient = NonNullable & { + session: { + create: (params?: AutoBriefSessionCreateParams) => Promise; + prompt: (params: AutoBriefSessionPromptParams) => Promise; + }; + tui: { + showToast: (payload: unknown) => Promise; + }; + }; + + function setupAuthoritativeWorkspace(workspaceDir: string): void { + const kbDir = path.join(workspaceDir, ".kb"); + fs.mkdirSync(kbDir, { recursive: true }); + fs.writeFileSync( + path.join(kbDir, "config.json"), + JSON.stringify( + { + paths: { + requirements: "documentation/requirements/**/*.md", + scenarios: "documentation/scenarios/**/*.md", + tests: "documentation/tests/**/*.md", + adr: "documentation/adr/**/*.md", + flags: "documentation/flags/**/*.md", + events: "documentation/events/**/*.md", + facts: "documentation/facts/**/*.md", + }, + }, + null, + 2, + ), + ); + + const docDirs = [ + "documentation/requirements", + "documentation/scenarios", + "documentation/tests", + "documentation/adr", + "documentation/flags", + "documentation/events", + "documentation/facts", + ]; + for (const dir of docDirs) { + fs.mkdirSync(path.join(workspaceDir, dir), { recursive: true }); + } + fs.writeFileSync(path.join(workspaceDir, "documentation", "symbols.yaml"), "[]"); + } + + function writePluginConfig(workspaceDir: string, config: Record): void { + const opencodeDir = path.join(workspaceDir, ".opencode"); + fs.mkdirSync(opencodeDir, { recursive: true }); + fs.writeFileSync(path.join(opencodeDir, "kibi.json"), JSON.stringify(config, null, 2)); + } + + function installNoopScheduler(workspaceDir: string): void { + const schedulerFactoryGlobals = globalThis as typeof globalThis & { + __kibi_test_scheduler_factory?: (...args: unknown[]) => unknown; + __kibi_test_scheduler_factory_by_worktree?: Map unknown>; + }; + const schedulerFactory = () => ({ + scheduleSync: () => {}, + onFileEdited: () => {}, + onToolExecuteAfter: () => {}, + flush: async () => {}, + dispose: () => {}, + }); + schedulerFactoryGlobals.__kibi_test_scheduler_factory_by_worktree ??= new Map(); + schedulerFactoryGlobals.__kibi_test_scheduler_factory_by_worktree.set( + workspaceDir, + schedulerFactory, + ); + schedulerFactoryGlobals.__kibi_test_scheduler_factory = schedulerFactory; + } + + function makeReadyPromptResponse( + overrides: Partial<{ + briefingState: string; + tldr: string; + promptBlock: string; + citations: Array>; + }> = {}, + ): unknown { + return { + data: { + info: { + id: "message-1", + role: "assistant", + }, + parts: [ + { + type: "text", + text: JSON.stringify({ + briefingState: "ready", + tldr: "Requirement context is ready.", + promptBlock: "- REQ-001: Honor the linked invariant.", + citations: [ + { + id: "REQ-001", + type: "req", + title: "Linked requirement", + }, + ], + ...overrides, + }), + }, + ], + }, + }; + } + + function createAutoBriefClient(options: { promptResults?: unknown[] } = {}) { + const createCalls: AutoBriefSessionCreateParams[] = []; + const promptCalls: AutoBriefSessionPromptParams[] = []; + const showToastCalls: unknown[] = []; + const logCalls: Record[] = []; + let promptCallIndex = 0; + + const client: AutoBriefClient = { + app: { + log: async (payload: Record) => { + logCalls.push(payload); + }, + }, + session: { + create: async (params?: AutoBriefSessionCreateParams) => { + createCalls.push(params ?? {}); + return { + data: { + id: "session-1", + }, + }; + }, + prompt: async (params: AutoBriefSessionPromptParams) => { + promptCalls.push(params); + const result = + options.promptResults?.[promptCallIndex] ?? + options.promptResults?.[options.promptResults.length - 1] ?? + makeReadyPromptResponse(); + promptCallIndex += 1; + return result; + }, + }, + tui: { + showToast: async (payload: unknown) => { + showToastCalls.push(payload); + return true; + }, + }, + }; + + return { + client, + createCalls, + promptCalls, + showToastCalls, + logCalls, + }; + } + + async function waitForCondition( + predicate: () => boolean, + attempts = 25, + ): Promise { + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (predicate()) { + return; + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + assert.fail("Timed out waiting for auto-brief async work"); + } + + async function loadFreshPlugin() { + freshPluginCounter += 1; + const mod = await import(`../src/index.ts?auto-brief=${freshPluginCounter}`); + return mod.default; + } + + it("triggers fetchBriefingResult for authoritative risky edits and sends a toast", async () => { + setupAuthoritativeWorkspace(tmpDir); + installNoopScheduler(tmpDir); + writePluginConfig(tmpDir, { + enabled: true, + prompt: { enabled: true, hookMode: "auto" }, + sync: { enabled: true }, + ux: { toastStartup: false }, + guidance: { + commentDetection: { enabled: false }, + smartEnforcement: { + completionReminder: false, + }, + }, + }); + + const srcDir = path.join(tmpDir, "src"); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync( + path.join(srcDir, "feature.ts"), + "export function feature() { return 42; } // implements REQ-001\n", + ); + + const { client, showToastCalls } = createAutoBriefClient(); + const fetchSpy = spyOn(briefingRuntimeModule, "fetchBriefingResult"); + const plugin = await loadFreshPlugin(); + const hooks = await plugin({ + ...makeInput({ client }), + workspace: "workspace://demo", + } as PluginInput & { workspace: string }); + + assert.ok(hooks.event); + const eventHook = hooks.event as (input: { + event: { type: string; properties: { file: string } }; + }) => Promise; + + await eventHook({ + event: { + type: "file.edited", + properties: { file: "src/feature.ts" }, + }, + }); + + await waitForCondition( + () => fetchSpy.mock.calls.length === 1 && showToastCalls.length === 1, + ); + + assert.equal(fetchSpy.mock.calls.length, 1); + assert.equal(fetchSpy.mock.calls[0]?.[0], client); + assert.equal( + (fetchSpy.mock.calls[0]?.[1] as { workspaceRoot: string }).workspaceRoot, + tmpDir, + ); + assert.equal( + (fetchSpy.mock.calls[0]?.[1] as { directory?: string }).directory, + tmpDir, + ); + assert.equal( + (fetchSpy.mock.calls[0]?.[1] as { workspace?: string }).workspace, + "workspace://demo", + ); + assert.equal( + (fetchSpy.mock.calls[0]?.[2] as { eligible: boolean }).eligible, + true, + ); + assert.deepEqual( + (fetchSpy.mock.calls[0]?.[2] as { sourceFiles: string[] }).sourceFiles, + ["src/feature.ts"], + ); + assert.equal( + (fetchSpy.mock.calls[0]?.[2] as { fingerprint: string }).fingerprint.endsWith( + "\0behavior_candidate", + ), + true, + ); + assert.deepEqual(showToastCalls[0], { + body: { + message: READY_TOAST, + }, + }); + }); + + it("reuses briefing-runtime cache for same-fingerprint repeated edits before guidance cache records", async () => { + setupAuthoritativeWorkspace(tmpDir); + installNoopScheduler(tmpDir); + writePluginConfig(tmpDir, { + enabled: true, + prompt: { enabled: true, hookMode: "auto" }, + sync: { enabled: true }, + ux: { toastStartup: false }, + guidance: { + commentDetection: { enabled: false }, + smartEnforcement: { + completionReminder: false, + }, + }, + }); + + const srcDir = path.join(tmpDir, "src"); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync( + path.join(srcDir, "feature.ts"), + "export function feature() { return 42; } // implements REQ-001\n", + ); + + const { client, createCalls, promptCalls } = createAutoBriefClient(); + const plugin = await loadFreshPlugin(); + const hooks = await plugin(makeInput({ client })); + + assert.ok(hooks.event); + const eventHook = hooks.event as (input: { + event: { type: string; properties: { file: string } }; + }) => Promise; + + await eventHook({ + event: { + type: "file.edited", + properties: { file: "src/feature.ts" }, + }, + }); + await waitForCondition(() => promptCalls.length === 1); + + await eventHook({ + event: { + type: "file.edited", + properties: { file: "src/feature.ts" }, + }, + }); + await waitForCondition(() => createCalls.length === 1 && promptCalls.length === 1); + + assert.equal(createCalls.length, 1); + assert.equal(promptCalls.length, 1); + }); + + it("does not call fetchBriefingResult for non-eligible or degraded contexts", async () => { + setupAuthoritativeWorkspace(tmpDir); + const fetchSpy = spyOn(briefingRuntimeModule, "fetchBriefingResult"); + + writePluginConfig(tmpDir, { + enabled: true, + prompt: { enabled: true, hookMode: "auto" }, + sync: { enabled: true }, + ux: { toastStartup: false }, + guidance: { + commentDetection: { enabled: false }, + smartEnforcement: { + completionReminder: false, + }, + }, + }); + + const { client: safeDocsClient } = createAutoBriefClient(); + installNoopScheduler(tmpDir); + const safeDocsPlugin = await loadFreshPlugin(); + const safeDocsHooks = await safeDocsPlugin(makeInput({ client: safeDocsClient })); + assert.ok(safeDocsHooks.event); + fs.writeFileSync(path.join(tmpDir, "README.md"), "# Safe docs\n"); + + const safeDocsEventHook = safeDocsHooks.event as (input: { + event: { type: string; properties: { file: string } }; + }) => Promise; + await safeDocsEventHook({ + event: { + type: "file.edited", + properties: { file: "README.md" }, + }, + }); + await Promise.resolve(); + + writePluginConfig(tmpDir, { + enabled: true, + prompt: { enabled: true, hookMode: "auto" }, + sync: { enabled: false }, + ux: { toastStartup: false }, + guidance: { + commentDetection: { enabled: false }, + smartEnforcement: { + completionReminder: false, + }, + }, + }); + + const srcDir = path.join(tmpDir, "src"); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync( + path.join(srcDir, "degraded.ts"), + "export function degraded() { return 1; } // implements REQ-001\n", + ); + const { client: degradedClient } = createAutoBriefClient(); + const degradedPlugin = await loadFreshPlugin(); + const degradedHooks = await degradedPlugin(makeInput({ client: degradedClient })); + assert.ok(degradedHooks.event); + + const degradedEventHook = degradedHooks.event as (input: { + event: { type: string; properties: { file: string } }; + }) => Promise; + await degradedEventHook({ + event: { + type: "file.edited", + properties: { file: "src/degraded.ts" }, + }, + }); + await Promise.resolve(); + + assert.equal(fetchSpy.mock.calls.length, 0); + }); + + it("passes the stored autoBriefResult to buildPrompt from the transform hook", async () => { + setupAuthoritativeWorkspace(tmpDir); + installNoopScheduler(tmpDir); + writePluginConfig(tmpDir, { + enabled: true, + prompt: { enabled: true, hookMode: "auto" }, + sync: { enabled: true }, + ux: { toastStartup: false }, + guidance: { + commentDetection: { enabled: false }, + smartEnforcement: { + completionReminder: false, + }, + }, + }); + + const srcDir = path.join(tmpDir, "src"); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync( + path.join(srcDir, "feature.ts"), + "export function feature() { return 42; } // implements REQ-001\n", + ); + + const expectedAutoBriefResult: BriefingRuntimeResult = { + state: "ready", + promptBlock: "- REQ-001: Honor the linked invariant.", + tldr: "Requirement context is ready.", + citations: [ + { + id: "REQ-001", + type: "req", + title: "Linked requirement", + }, + ], + showManualCue: false, + toastMessage: READY_TOAST, + }; + const { client, promptCalls } = createAutoBriefClient({ + promptResults: [ + makeReadyPromptResponse({ + tldr: expectedAutoBriefResult.tldr, + promptBlock: expectedAutoBriefResult.promptBlock, + citations: expectedAutoBriefResult.citations.map((citation) => ({ + id: citation.id, + type: citation.type ?? "", + title: citation.title ?? "", + })), + }), + ], + }); + const buildPromptSpy = spyOn(promptModule, "buildPrompt"); + const plugin = await loadFreshPlugin(); + const hooks = await plugin(makeInput({ client })); + + assert.ok(hooks.event); + assert.ok(hooks["experimental.chat.system.transform"]); + + const eventHook = hooks.event as (input: { + event: { type: string; properties: { file: string } }; + }) => Promise; + await eventHook({ + event: { + type: "file.edited", + properties: { file: "src/feature.ts" }, + }, + }); + await waitForCondition(() => promptCalls.length === 1); + + const transformHook = hooks["experimental.chat.system.transform"] as ( + input: unknown, + output: { system: string[] }, + ) => Promise; + await transformHook({}, { system: ["prompt"] }); + + assert.ok(buildPromptSpy.mock.calls.length >= 1); + const buildPromptContext = buildPromptSpy.mock.calls.at(-1)?.[0] as { + autoBriefResult?: BriefingRuntimeResult; + }; + assert.deepEqual(buildPromptContext.autoBriefResult, expectedAutoBriefResult); + }); + }); + // implements REQ-opencode-smart-enforcement-v1 describe("runtime degraded overlay", () => { it("latches sync_disabled when sync.enabled=false", async () => { From 40a19eff447e5ae5f7f87b463f619ddadcfba052 Mon Sep 17 00:00:00 2001 From: Piotr Franczyk Date: Thu, 23 Apr 2026 13:18:24 +0200 Subject: [PATCH 08/17] feat(opencode): trigger auto briefing from event path --- packages/opencode/src/index.ts | 12 +++--- packages/opencode/tests/index.test.ts | 62 +++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index dee39bc..81e10a5 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -215,6 +215,9 @@ const kibiOpencodePlugin: Plugin = async ( riskClass === "safe_docs_only" && precomputedSuggestion ? "traceability_candidate" : riskClass; + const isAutoBriefRisk = + effectiveRiskClass === "behavior_candidate" || + effectiveRiskClass === "traceability_candidate"; lastRiskClass = effectiveRiskClass; lastEditedFilePath = filePath; @@ -358,7 +361,9 @@ const kibiOpencodePlugin: Plugin = async ( posture: posture.state, posture_state: posture.state, }); - return; + if (!isAutoBriefRisk) { + return; + } } logger.info("smart-enforcement.cache", { @@ -467,10 +472,7 @@ const kibiOpencodePlugin: Plugin = async ( return; } - if ( - effectiveRiskClass === "behavior_candidate" || - effectiveRiskClass === "traceability_candidate" - ) { + if (isAutoBriefRisk) { if ( pathAnalysis.kind === "code" && cfg.guidance.commentDetection.enabled diff --git a/packages/opencode/tests/index.test.ts b/packages/opencode/tests/index.test.ts index 6fe47aa..285acd8 100644 --- a/packages/opencode/tests/index.test.ts +++ b/packages/opencode/tests/index.test.ts @@ -3489,6 +3489,68 @@ import datetime assert.equal(promptCalls.length, 1); }); + it("still calls fetchBriefingResult after guidance cache is satisfied for the same risky edit", async () => { + setupAuthoritativeWorkspace(tmpDir); + installNoopScheduler(tmpDir); + writePluginConfig(tmpDir, { + enabled: true, + prompt: { enabled: true, hookMode: "auto" }, + sync: { enabled: true }, + ux: { toastStartup: false }, + guidance: { + commentDetection: { enabled: false }, + smartEnforcement: { + completionReminder: false, + }, + }, + }); + + const srcDir = path.join(tmpDir, "src"); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync( + path.join(srcDir, "feature.ts"), + "export function feature() { return 42; } // implements REQ-001\n", + ); + + const { client, createCalls, promptCalls } = createAutoBriefClient(); + const fetchSpy = spyOn(briefingRuntimeModule, "fetchBriefingResult"); + const plugin = await loadFreshPlugin(); + const hooks = await plugin(makeInput({ client })); + + assert.ok(hooks.event); + assert.ok(hooks["experimental.chat.system.transform"]); + + const eventHook = hooks.event as (input: { + event: { type: string; properties: { file: string } }; + }) => Promise; + const transformHook = hooks["experimental.chat.system.transform"] as ( + input: unknown, + output: { system: string[] }, + ) => Promise; + + await eventHook({ + event: { + type: "file.edited", + properties: { file: "src/feature.ts" }, + }, + }); + await waitForCondition(() => fetchSpy.mock.calls.length === 1 && promptCalls.length === 1); + + await transformHook({}, { system: ["prompt"] }); + + await eventHook({ + event: { + type: "file.edited", + properties: { file: "src/feature.ts" }, + }, + }); + await waitForCondition(() => fetchSpy.mock.calls.length === 2); + + assert.equal(fetchSpy.mock.calls.length, 2); + assert.equal(createCalls.length, 1); + assert.equal(promptCalls.length, 1); + }); + it("does not call fetchBriefingResult for non-eligible or degraded contexts", async () => { setupAuthoritativeWorkspace(tmpDir); const fetchSpy = spyOn(briefingRuntimeModule, "fetchBriefingResult"); From 916d2eb3f6591db102c7129b0d65af0e8283b488 Mon Sep 17 00:00:00 2001 From: Piotr Franczyk Date: Thu, 23 Apr 2026 13:34:32 +0200 Subject: [PATCH 09/17] feat(opencode): surface auto briefing in prompt guidance --- packages/opencode/src/prompt.ts | 82 +++++++-- packages/opencode/tests/prompt.test.ts | 242 ++++++++++++++++++++++++- 2 files changed, 310 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/prompt.ts b/packages/opencode/src/prompt.ts index 042b97b..7a80d85 100644 --- a/packages/opencode/src/prompt.ts +++ b/packages/opencode/src/prompt.ts @@ -15,7 +15,9 @@ const SENTINEL = ""; // ── Token budget enforcement ─────────────────────────────────────────── const MAX_BULLETS = 5; +const MAX_AUTO_BRIEF_BULLETS_WITH_REMINDER = 4; const MAX_WORDS = 117; // Reserve 3 words for sentinel so total injected prompt stays ≀ 120 +const AUTO_BRIEF_HEADER = "🧠 **Kibi briefing available**"; const AUTHORITATIVE_POSTURES: RepoPosture[] = [ "root_active", @@ -58,6 +60,39 @@ function insertBulletAfterHeader(block: string, bullet: string): string { return `${block.slice(0, headerEnd + 1)}${bullet}\n${block.slice(headerEnd + 1)}`; } +// implements REQ-opencode-kibi-briefing-v2 +function buildAutoBriefingGuidance( + autoBriefResult: BriefingRuntimeResult | undefined, + completionReminder: boolean, +): string | null { + if (!autoBriefResult) return null; + + if (autoBriefResult.state === "ready") { + const promptBlock = autoBriefResult.promptBlock.trim(); + if (!promptBlock) return null; + + const maxBullets = completionReminder + ? MAX_AUTO_BRIEF_BULLETS_WITH_REMINDER + : MAX_BULLETS; + const briefingLines = promptBlock + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("-")) + .slice(0, maxBullets); + + if (briefingLines.length === 0) return null; + return `${AUTO_BRIEF_HEADER}\n${briefingLines.join("\n")}`; + } + + if (autoBriefResult.state === "tldr_fallback") { + const promptBlock = autoBriefResult.promptBlock.trim(); + if (!promptBlock) return null; + return `${AUTO_BRIEF_HEADER}\n${promptBlock}`; + } + + return null; +} + function isAuthoritativePosture(posture: RepoPosture): boolean { return AUTHORITATIVE_POSTURES.includes(posture); } @@ -171,6 +206,12 @@ Root .kb/config.json exists but some configured KB targets are missing. Guidance function buildContextualGuidance(context: PromptContext): string { const posture = context.posture ?? "root_active"; const riskClass = context.riskClass; + const readyAutoBriefingAvailable = + context.autoBriefResult?.state === "ready" && + context.autoBriefResult.promptBlock.trim() !== ""; + const suppressSourceLinkedBrief = + context.autoBriefResult?.state === "ready" || + context.autoBriefResult?.state === "tldr_fallback"; const showDegraded = context.showDegradedAdvisory === true && context.maintenanceDegraded === true && @@ -234,18 +275,31 @@ Do not run \`kibi\` CLI commands directly; use public MCP tools (kb_autopilot_ge riskClass !== "safe_docs_only" && riskClass !== "safe_test_only" ) { - // For behavior/traceability with comment suggestions, use suggestion guidance - if ( - (riskClass === "behavior_candidate" || - riskClass === "traceability_candidate") && - context.recentCommentSuggestion - ) { - selectedBlock = buildCommentSuggestionGuidance( - context.recentCommentSuggestion, - ); + const autoBriefBlock = + riskClass === "behavior_candidate" || + riskClass === "traceability_candidate" + ? buildAutoBriefingGuidance( + context.autoBriefResult, + context.completionReminder === true, + ) + : null; + + if (autoBriefBlock) { + selectedBlock = autoBriefBlock; } else { - const block = GUIDANCE_BY_RISK[riskClass]; - if (block) selectedBlock = block; + // For behavior/traceability with comment suggestions, use suggestion guidance + if ( + (riskClass === "behavior_candidate" || + riskClass === "traceability_candidate") && + context.recentCommentSuggestion + ) { + selectedBlock = buildCommentSuggestionGuidance( + context.recentCommentSuggestion, + ); + } else { + const block = GUIDANCE_BY_RISK[riskClass]; + if (block) selectedBlock = block; + } } } // Priority 6: Legacy path-kind fallback (when no risk class) @@ -298,7 +352,8 @@ If you're adding long explanatory comments, consider routing that knowledge to: (riskClass === "behavior_candidate" || riskClass === "traceability_candidate") && isAuthoritativePosture(posture) && - !context.maintenanceDegraded + !context.maintenanceDegraded && + !readyAutoBriefingAvailable ) { selectedBlock = insertBulletAfterHeader( selectedBlock, @@ -314,7 +369,8 @@ If you're adding long explanatory comments, consider routing that knowledge to: selectedBlock && (riskClass === "behavior_candidate" || riskClass === "traceability_candidate") && - context.workspaceRoot + context.workspaceRoot && + !suppressSourceLinkedBrief ) { try { const lastEdit = context.recentEdits[context.recentEdits.length - 1]; diff --git a/packages/opencode/tests/prompt.test.ts b/packages/opencode/tests/prompt.test.ts index 692b617..6013fdf 100644 --- a/packages/opencode/tests/prompt.test.ts +++ b/packages/opencode/tests/prompt.test.ts @@ -4,10 +4,16 @@ import { strict as assert } from "node:assert"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { BriefingRuntimeResult } from "../src/briefing-runtime"; import type { KibiConfig } from "../src/config"; import { GuidanceCache } from "../src/guidance-cache"; import type { CacheKey } from "../src/guidance-cache"; -import { SENTINEL, buildPrompt, injectPrompt } from "../src/prompt"; +import { + SENTINEL, + buildPrompt, + injectPrompt, + type PromptContext, +} from "../src/prompt"; const baseConfig: KibiConfig = { enabled: true, @@ -39,6 +45,20 @@ const baseConfig: KibiConfig = { logLevel: "info", }; +function makeAutoBriefResult( + overrides: Partial = {}, +): BriefingRuntimeResult { + return { + state: "ready", + promptBlock: "- REQ-001: Auto summary", + tldr: "Auto summary", + citations: [], + showManualCue: true, + toastMessage: "Kibi brief ready β€” summary added to guidance.", + ...overrides, + }; +} + describe("prompt", () => { test("buildPrompt returns guidance with sentinel", () => { const p = buildPrompt(); @@ -1038,6 +1058,226 @@ describe("completion reminder policy", () => { }); }); +describe("auto-brief prompt rendering", () => { + const BRIEF_KIBI_CUE = + "Authoritative risky edit: run `/brief-kibi` before acting."; + const REMINDER_TEXT = "Run `kb_check` before completing this task."; + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kibi-auto-brief-")); + }); + + afterEach(() => { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch {} + }); + + function writeSymbolsYamlForPrompt(): void { + const docDir = path.join(tmpDir, "documentation"); + fs.mkdirSync(docDir, { recursive: true }); + fs.writeFileSync( + path.join(docDir, "symbols.yaml"), + [ + "symbols:", + " - id: SYM-buildPrompt", + " sourceFile: packages/opencode/src/prompt.ts", + " links:", + " - REQ-opencode-kibi-briefing-v2", + ].join("\n"), + ); + } + + function buildRiskyPrompt( + overrides: Partial = {}, + ): string { + const context: PromptContext = { + recentEdits: [{ path: "packages/opencode/src/prompt.ts", kind: "code" }], + posture: "root_active", + riskClass: "behavior_candidate", + }; + + return buildPrompt({ ...context, ...overrides }); + } + + test("renders ready-state auto-brief block and suppresses the risky cue", () => { + const p = buildRiskyPrompt({ + autoBriefResult: makeAutoBriefResult({ + state: "ready", + promptBlock: "- REQ-001: Session timeout\n- REQ-002: Session invalidation", + }), + }); + + assert.ok( + p.includes("🧠 **Kibi briefing available**"), + "Should render the auto-brief header", + ); + assert.ok( + p.includes("- REQ-001: Session timeout"), + "Should render first imported briefing bullet", + ); + assert.ok( + p.includes("- REQ-002: Session invalidation"), + "Should render second imported briefing bullet", + ); + assert.ok( + !p.includes(BRIEF_KIBI_CUE), + "Should suppress /brief-kibi cue when a ready-state prompt block exists", + ); + assert.ok( + !p.includes("Code changes detected"), + "Should replace the normal risky guidance body", + ); + }); + + test("ready-state auto-brief suppresses source-linked micro-brief insertion", () => { + writeSymbolsYamlForPrompt(); + + const p = buildRiskyPrompt({ + workspaceRoot: tmpDir, + autoBriefResult: makeAutoBriefResult({ + state: "ready", + promptBlock: "- REQ-001: Session timeout", + }), + }); + + assert.ok( + !p.includes("- Existing Kibi links:"), + "Should suppress source-linked micro-brief when rendering ready-state auto-brief content", + ); + }); + + test("tldr fallback keeps the manual cue and suppresses source-linked micro-brief insertion", () => { + writeSymbolsYamlForPrompt(); + + const p = buildRiskyPrompt({ + workspaceRoot: tmpDir, + autoBriefResult: makeAutoBriefResult({ + state: "tldr_fallback", + promptBlock: "- Session rules summary\n- Full details: run /brief-kibi.", + toastMessage: + "Kibi brief summary added β€” use /brief-kibi for full details.", + }), + }); + + assert.ok( + p.includes("🧠 **Kibi briefing available**"), + "Should render the fallback auto-brief header", + ); + assert.ok( + p.includes("- Session rules summary"), + "Should render the TLDR fallback content", + ); + assert.ok( + p.includes(BRIEF_KIBI_CUE), + "Should keep the outer /brief-kibi cue for TLDR fallback", + ); + assert.ok( + !p.includes("- Existing Kibi links:"), + "Should suppress source-linked micro-brief when fallback content is present", + ); + }); + + test("no_briefing auto-brief result preserves the existing risky guidance path", () => { + const baseline = buildRiskyPrompt(); + const withNoBriefing = buildRiskyPrompt({ + autoBriefResult: makeAutoBriefResult({ + state: "no_briefing", + promptBlock: "", + tldr: "", + toastMessage: "Kibi brief unavailable β€” keeping /brief-kibi manual path.", + }), + }); + + assert.equal( + withNoBriefing, + baseline, + "no_briefing should behave identically to the pre-existing risky guidance path", + ); + }); + + test("ready-state auto-brief still respects the 5-bullet prompt budget without a reminder", () => { + const p = buildRiskyPrompt({ + autoBriefResult: makeAutoBriefResult({ + state: "ready", + promptBlock: [ + "- REQ-001: One", + "- REQ-002: Two", + "- REQ-003: Three", + "- REQ-004: Four", + "- REQ-005: Five", + "- REQ-006: Six", + ].join("\n"), + }), + }); + + const importedBullets = p + .split("\n") + .filter((line) => line.startsWith("- REQ-")); + + assert.equal( + importedBullets.length, + 5, + "Ready-state imported briefing content should cap at 5 bullets without a reminder", + ); + assert.ok(!p.includes("- REQ-006: Six"), "Sixth bullet should be trimmed"); + }); + + test("completion reminder trims ready-state imported briefing bullets to four", () => { + const p = buildRiskyPrompt({ + completionReminder: true, + autoBriefResult: makeAutoBriefResult({ + state: "ready", + promptBlock: [ + "- REQ-001: One", + "- REQ-002: Two", + "- REQ-003: Three", + "- REQ-004: Four", + "- REQ-005: Five", + "- REQ-006: Six", + ].join("\n"), + }), + }); + + const importedBullets = p + .split("\n") + .filter((line) => line.startsWith("- REQ-")); + const allBullets = p + .split("\n") + .filter((line) => line.trimStart().startsWith("-")); + + assert.equal( + importedBullets.length, + 4, + "Ready-state imported briefing content should cap at 4 bullets when reminder is enabled", + ); + assert.ok( + p.includes(REMINDER_TEXT), + "Should still append the completion reminder", + ); + assert.equal( + allBullets.length, + 5, + "Imported bullets plus reminder should stay within the 5-bullet cap", + ); + assert.ok(!p.includes("- REQ-005: Five"), "Fifth imported bullet should be trimmed"); + }); + + test("ready-state auto-brief stays inside a single contextual block", () => { + const p = buildRiskyPrompt({ + completionReminder: true, + autoBriefResult: makeAutoBriefResult({ + state: "ready", + promptBlock: "- REQ-001: Session timeout\n- REQ-002: Session invalidation", + }), + }); + + const blocks = p.split(SENTINEL).filter((segment) => segment.trim().length > 0); + assert.equal(blocks.length, 1, "Auto-brief rendering must stay within one contextual block"); + }); +}); + // ── Source-linked micro-brief contract (Task 1 TDD lock-in) ─────────── // These tests define the contract for Task 2 implementation. // Expected to FAIL until runtime source-linked guidance is implemented. From 3069d596d1330c78b66d7539365bb0c361507c47 Mon Sep 17 00:00:00 2001 From: Piotr Franczyk Date: Thu, 23 Apr 2026 14:00:45 +0200 Subject: [PATCH 10/17] fix(opencode): deduplicate toast emission per fingerprint --- packages/opencode/src/index.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 81e10a5..efaa739 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -165,6 +165,7 @@ const kibiOpencodePlugin: Plugin = async ( let recentCommentSuggestion: CommentAnalysisResult | null = null; const seenFingerprints = new Set(); // For deduplication const autoBriefResults = new Map(); + const toastedFingerprints = new Set(); let lastRiskClass: RiskClass | null = null; let lastEditedFilePath: string | null = null; let degradedWarnedOnce = false; @@ -526,6 +527,7 @@ const kibiOpencodePlugin: Plugin = async ( posture.state === "hybrid_root_plus_vendored") ) { const client = input.client; + const fingerprint = intentResult.fingerprint; const workspaceCtx: BriefingWorkspaceCtx = { workspaceRoot: input.worktree, branch: currentBranch, @@ -534,8 +536,11 @@ const kibiOpencodePlugin: Plugin = async ( }; void fetchBriefingResult(client, workspaceCtx, intentResult).then((result) => { - autoBriefResults.set(intentResult.fingerprint, result); - void sendToast(client, { message: result.toastMessage }); + autoBriefResults.set(fingerprint, result); + if (!toastedFingerprints.has(fingerprint)) { + toastedFingerprints.add(fingerprint); + void sendToast(client, { message: result.toastMessage }); + } }); } } From f538e6f2b81dfa7e81c7ef46454ca8910ca9092e Mon Sep 17 00:00:00 2001 From: Piotr Franczyk Date: Thu, 23 Apr 2026 14:01:34 +0200 Subject: [PATCH 11/17] test(opencode): cover auto briefing runtime paths --- packages/opencode/tests/index.test.ts | 286 ++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) diff --git a/packages/opencode/tests/index.test.ts b/packages/opencode/tests/index.test.ts index 285acd8..73e5105 100644 --- a/packages/opencode/tests/index.test.ts +++ b/packages/opencode/tests/index.test.ts @@ -3437,6 +3437,292 @@ import datetime }); }); + it("sends exactly one toast for repeated same-fingerprint edit events", async () => { + setupAuthoritativeWorkspace(tmpDir); + installNoopScheduler(tmpDir); + writePluginConfig(tmpDir, { + enabled: true, + prompt: { enabled: true, hookMode: "auto" }, + sync: { enabled: true }, + ux: { toastStartup: false }, + guidance: { + commentDetection: { enabled: false }, + smartEnforcement: { + completionReminder: false, + }, + }, + }); + + const srcDir = path.join(tmpDir, "src"); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync( + path.join(srcDir, "feature.ts"), + "export function feature() { return 42; } // implements REQ-001\n", + ); + + const expectedAutoBriefResult: BriefingRuntimeResult = { + state: "ready", + promptBlock: "- REQ-001: Honor the linked invariant.", + tldr: "Requirement context is ready.", + citations: [], + showManualCue: false, + toastMessage: READY_TOAST, + }; + const { client, showToastCalls } = createAutoBriefClient(); + let resolveBriefing: ((result: BriefingRuntimeResult) => void) | undefined; + const briefingGate = new Promise((resolve) => { + resolveBriefing = resolve; + }); + const fetchSpy = spyOn(briefingRuntimeModule, "fetchBriefingResult").mockImplementation( + () => briefingGate, + ); + const plugin = await loadFreshPlugin(); + const hooks = await plugin(makeInput({ client })); + + assert.ok(hooks.event); + const eventHook = hooks.event as (input: { + event: { type: string; properties: { file: string } }; + }) => Promise; + + await eventHook({ + event: { + type: "file.edited", + properties: { file: "src/feature.ts" }, + }, + }); + await eventHook({ + event: { + type: "file.edited", + properties: { file: "src/feature.ts" }, + }, + }); + await waitForCondition(() => fetchSpy.mock.calls.length === 2); + + resolveBriefing?.(expectedAutoBriefResult); + await waitForCondition(() => showToastCalls.length > 0); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(fetchSpy.mock.calls.length, 2); + assert.equal(showToastCalls.length, 1); + assert.deepEqual(showToastCalls[0], { + body: { + message: READY_TOAST, + }, + }); + }); + + it("renders ready auto-brief guidance without the inline /brief-kibi cue", async () => { + setupAuthoritativeWorkspace(tmpDir); + installNoopScheduler(tmpDir); + writePluginConfig(tmpDir, { + enabled: true, + prompt: { enabled: true, hookMode: "auto" }, + sync: { enabled: true }, + ux: { toastStartup: false }, + guidance: { + commentDetection: { enabled: false }, + smartEnforcement: { + completionReminder: false, + }, + }, + }); + + const srcDir = path.join(tmpDir, "src"); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync( + path.join(srcDir, "feature.ts"), + "export function feature() { return 42; } // implements REQ-001\n", + ); + + const { client, promptCalls, showToastCalls } = createAutoBriefClient({ + promptResults: [ + makeReadyPromptResponse({ + tldr: "Requirement context is ready.", + promptBlock: "- REQ-001: Honor the linked invariant.\n- SCEN-001: Preserve the canonical flow.", + citations: [ + { + id: "REQ-001", + type: "req", + title: "Linked requirement", + }, + ], + }), + ], + }); + const plugin = await loadFreshPlugin(); + const hooks = await plugin(makeInput({ client })); + + assert.ok(hooks.event); + assert.ok(hooks["experimental.chat.system.transform"]); + + const eventHook = hooks.event as (input: { + event: { type: string; properties: { file: string } }; + }) => Promise; + const transformHook = hooks["experimental.chat.system.transform"] as ( + input: unknown, + output: { system: string[] }, + ) => Promise; + + await eventHook({ + event: { + type: "file.edited", + properties: { file: "src/feature.ts" }, + }, + }); + await waitForCondition(() => promptCalls.length === 1 && showToastCalls.length === 1); + + const output = { system: ["prompt"] }; + await transformHook({}, output); + + const rendered = output.system.at(-1) ?? ""; + assert.ok(rendered.includes("🧠 **Kibi briefing available**")); + assert.ok(rendered.includes("- REQ-001: Honor the linked invariant.")); + assert.ok(!rendered.includes("Authoritative risky edit: run `/brief-kibi` before acting.")); + }); + + it("renders tldr fallback guidance with the manual /brief-kibi path preserved", async () => { + setupAuthoritativeWorkspace(tmpDir); + installNoopScheduler(tmpDir); + writePluginConfig(tmpDir, { + enabled: true, + prompt: { enabled: true, hookMode: "auto" }, + sync: { enabled: true }, + ux: { toastStartup: false }, + guidance: { + commentDetection: { enabled: false }, + smartEnforcement: { + completionReminder: false, + }, + }, + }); + + const srcDir = path.join(tmpDir, "src"); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync( + path.join(srcDir, "feature.ts"), + "export function feature() { return 42; } // implements REQ-001\n", + ); + + const { client, promptCalls, showToastCalls } = createAutoBriefClient({ + promptResults: [ + makeReadyPromptResponse({ + tldr: "Some summary here", + promptBlock: "", + citations: [ + { + id: "REQ-001", + type: "req", + title: "Linked requirement", + }, + ], + }), + ], + }); + const plugin = await loadFreshPlugin(); + const hooks = await plugin(makeInput({ client })); + + assert.ok(hooks.event); + assert.ok(hooks["experimental.chat.system.transform"]); + + const eventHook = hooks.event as (input: { + event: { type: string; properties: { file: string } }; + }) => Promise; + const transformHook = hooks["experimental.chat.system.transform"] as ( + input: unknown, + output: { system: string[] }, + ) => Promise; + + await eventHook({ + event: { + type: "file.edited", + properties: { file: "src/feature.ts" }, + }, + }); + await waitForCondition(() => promptCalls.length === 1 && showToastCalls.length === 1); + + const renderedOutput = { system: ["prompt"] }; + await transformHook({}, renderedOutput); + + const rendered = renderedOutput.system.at(-1) ?? ""; + assert.ok(rendered.includes("🧠 **Kibi briefing available**")); + assert.ok(rendered.includes("Some summary here")); + assert.ok(rendered.includes("Authoritative risky edit: run `/brief-kibi` before acting.")); + assert.ok(rendered.includes("Full details: run /brief-kibi.")); + }); + + it("does not surface fabricated auto-brief content when runtime reports no_briefing", async () => { + setupAuthoritativeWorkspace(tmpDir); + installNoopScheduler(tmpDir); + writePluginConfig(tmpDir, { + enabled: true, + prompt: { enabled: true, hookMode: "auto" }, + sync: { enabled: true }, + ux: { toastStartup: false }, + guidance: { + commentDetection: { enabled: false }, + smartEnforcement: { + completionReminder: false, + }, + }, + }); + + const srcDir = path.join(tmpDir, "src"); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync( + path.join(srcDir, "feature.ts"), + "export function feature() { return 42; } // implements REQ-001\n", + ); + + const { client, promptCalls, showToastCalls } = createAutoBriefClient({ + promptResults: [ + makeReadyPromptResponse({ + briefingState: "no_briefing", + tldr: "This text must not be surfaced.", + promptBlock: "- fabricated", + citations: [ + { + id: "REQ-001", + type: "req", + title: "Linked requirement", + }, + ], + }), + ], + }); + const plugin = await loadFreshPlugin(); + const hooks = await plugin(makeInput({ client })); + + assert.ok(hooks.event); + assert.ok(hooks["experimental.chat.system.transform"]); + + const eventHook = hooks.event as (input: { + event: { type: string; properties: { file: string } }; + }) => Promise; + const transformHook = hooks["experimental.chat.system.transform"] as ( + input: unknown, + output: { system: string[] }, + ) => Promise; + + await eventHook({ + event: { + type: "file.edited", + properties: { file: "src/feature.ts" }, + }, + }); + await waitForCondition(() => promptCalls.length === 1 && showToastCalls.length === 1); + + const renderedOutput = { system: ["prompt"] }; + await transformHook({}, renderedOutput); + + const rendered = renderedOutput.system.at(-1) ?? ""; + assert.ok(rendered.includes("πŸ“ **Code changes detected**")); + assert.ok(rendered.includes("Authoritative risky edit: run `/brief-kibi` before acting.")); + assert.ok(!rendered.includes("🧠 **Kibi briefing available**")); + assert.ok(!rendered.includes("This text must not be surfaced.")); + assert.ok(!rendered.includes("- fabricated")); + }); + it("reuses briefing-runtime cache for same-fingerprint repeated edits before guidance cache records", async () => { setupAuthoritativeWorkspace(tmpDir); installNoopScheduler(tmpDir); From f9258c6abbe02a8f1eb31ff3ead29936a26baccb Mon Sep 17 00:00:00 2001 From: Piotr Franczyk Date: Thu, 23 Apr 2026 14:23:07 +0200 Subject: [PATCH 12/17] docs(opencode): describe auto briefing v2 --- .changeset/kibi-opencode-auto-brief-v2.md | 5 +++++ packages/opencode/README.md | 9 +++++---- 2 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 .changeset/kibi-opencode-auto-brief-v2.md diff --git a/.changeset/kibi-opencode-auto-brief-v2.md b/.changeset/kibi-opencode-auto-brief-v2.md new file mode 100644 index 0000000..8c91012 --- /dev/null +++ b/.changeset/kibi-opencode-auto-brief-v2.md @@ -0,0 +1,5 @@ +--- +"kibi-opencode": minor +--- + +OpenCode now auto-fetches Kibi briefings from the event path when authoritative risky edits are detected. Ready-state briefings appear in a toast notification and inside the agent guidance block (headed `🧠 **Kibi briefing available**`). The `/brief-kibi` manual command remains available as a fallback. diff --git a/packages/opencode/README.md b/packages/opencode/README.md index 536635d..502eaf6 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -115,11 +115,12 @@ OpenCode exposes Kibi MCP prompts as slash commands. The \`/init-kibi\` command ### Start-Task Briefing -Use `/brief-kibi` at the start of authoritative risky edit work when the prompt hints for it. +When the plugin detects an authoritative risky edit (`behavior_candidate` or `traceability_candidate` risk class), it automatically fetches a Kibi briefing from a background worker session via the `file.edited` event path. Auto-briefing is no longer deferred and provides immediate project context before you act. -- The plugin only hints about `/brief-kibi` inside smart-enforcement guidance. -- The actual briefing content is generated by the registered MCP prompt/tool, not by the prompt hook. -- Live automatic briefing fetch from `experimental.chat.system.transform` is deferred; the hook remains text-only. +- **Automatic delivery**: Briefings appear in a toast notification and inside the guidance block headed `🧠 **Kibi briefing available**`. +- **Contextual richness**: The briefing includes a summary and key source-linked bullets generated by the `kb_briefing_generate` MCP tool. +- **TL;DR fallback**: If a full briefing is unavailable, a summary is provided with a cue to use the manual command. +- **Manual command**: Use `/brief-kibi` at any time to trigger an on-demand briefing if auto-delivery is skipped or fails. ### Discovery-first MCP guidance From 4b87b7dff7573cd6551b946a7557c20b8ba8a511 Mon Sep 17 00:00:00 2001 From: Piotr Franczyk Date: Thu, 23 Apr 2026 15:16:22 +0200 Subject: [PATCH 13/17] fix(opencode): remove dead code, fix budget overflow, add sendToast error handling --- packages/opencode/src/brief-intent.ts | 6 - packages/opencode/src/index.ts | 4 +- packages/opencode/src/prompt.ts | 35 ++++-- packages/opencode/src/toast.ts | 8 +- packages/opencode/tests/brief-intent.test.ts | 7 +- .../tests/briefing-auto-render.test.ts | 3 +- packages/opencode/tests/index.test.ts | 112 ++++++++++++++++++ packages/opencode/tests/prompt.test.ts | 70 ++++++++++- 8 files changed, 213 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/brief-intent.ts b/packages/opencode/src/brief-intent.ts index 2aa00b7..8780122 100644 --- a/packages/opencode/src/brief-intent.ts +++ b/packages/opencode/src/brief-intent.ts @@ -30,7 +30,6 @@ export interface BriefIntentResult { fingerprint: string; sourceFiles: string[]; seedIds: string[]; - keepManualCue: boolean; } export interface BriefIntentInputs { @@ -79,7 +78,6 @@ export function deriveBriefIntent( fingerprint, sourceFiles, seedIds, - keepManualCue: true, }; } @@ -90,7 +88,6 @@ export function deriveBriefIntent( fingerprint, sourceFiles, seedIds, - keepManualCue: true, }; } @@ -101,7 +98,6 @@ export function deriveBriefIntent( fingerprint, sourceFiles, seedIds, - keepManualCue: true, }; } @@ -112,7 +108,6 @@ export function deriveBriefIntent( fingerprint, sourceFiles, seedIds, - keepManualCue: true, }; } @@ -122,7 +117,6 @@ export function deriveBriefIntent( fingerprint, sourceFiles, seedIds, - keepManualCue: true, }; } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index efaa739..df808f4 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -539,7 +539,9 @@ const kibiOpencodePlugin: Plugin = async ( autoBriefResults.set(fingerprint, result); if (!toastedFingerprints.has(fingerprint)) { toastedFingerprints.add(fingerprint); - void sendToast(client, { message: result.toastMessage }); + void sendToast(client, { message: result.toastMessage }).catch(() => { + // toast delivery failure is non-fatal + }); } }); } diff --git a/packages/opencode/src/prompt.ts b/packages/opencode/src/prompt.ts index 7a80d85..c1c5343 100644 --- a/packages/opencode/src/prompt.ts +++ b/packages/opencode/src/prompt.ts @@ -32,14 +32,14 @@ function countBullets(lines: string[]): number { return lines.filter((l) => l.startsWith("-")).length; } -function enforceBudget(block: string): string { +function enforceBudget(block: string, maxBullets: number = MAX_BULLETS): string { const lines = block.split("\n"); - if (countBullets(lines) > MAX_BULLETS || countWords(block) > MAX_WORDS) { - // Trim to budget: keep header + first MAX_BULLETS bullet lines + if (countBullets(lines) > maxBullets || countWords(block) > MAX_WORDS) { + // Trim to budget: keep header + first maxBullets bullet lines const header: string[] = []; const bullets: string[] = []; for (const line of lines) { - if (line.startsWith("-") && bullets.length < MAX_BULLETS) { + if (line.startsWith("-") && bullets.length < maxBullets) { bullets.push(line); } else if (!line.startsWith("-")) { if (bullets.length === 0) header.push(line); @@ -207,8 +207,7 @@ function buildContextualGuidance(context: PromptContext): string { const posture = context.posture ?? "root_active"; const riskClass = context.riskClass; const readyAutoBriefingAvailable = - context.autoBriefResult?.state === "ready" && - context.autoBriefResult.promptBlock.trim() !== ""; + context.autoBriefResult?.showManualCue === false; const suppressSourceLinkedBrief = context.autoBriefResult?.state === "ready" || context.autoBriefResult?.state === "tldr_fallback"; @@ -427,16 +426,30 @@ The Kibi workspace is in a maintenance-degraded state. Guidance remains advisory context.cache.recordSatisfied(key, "guidance"); } - // Apply budget enforcement before appending the completion reminder so the - // reminder bullet is never silently trimmed when bullet count exceeds MAX_BULLETS. - const budgeted = selectedBlock ? enforceBudget(selectedBlock) : null; - - // Append completion reminder for risky classes when enabled const REMINDER_RISK_CLASSES: RiskClass[] = [ "behavior_candidate", "traceability_candidate", "req_policy_candidate", ]; + const reminderWillBeAppended = + !!selectedBlock && + context.completionReminder === true && + !context.maintenanceDegraded && + riskClass != null && + REMINDER_RISK_CLASSES.includes(riskClass) && + posture !== "root_uninitialized" && + posture !== "root_partial"; + const effectiveMaxBullets = reminderWillBeAppended + ? MAX_BULLETS - 1 + : MAX_BULLETS; + + // Apply budget enforcement before appending the completion reminder so the + // reminder bullet is never silently trimmed when bullet count exceeds MAX_BULLETS. + const budgeted = selectedBlock + ? enforceBudget(selectedBlock, effectiveMaxBullets) + : null; + + // Append completion reminder for risky classes when enabled let finalBlock = budgeted; if ( finalBlock && diff --git a/packages/opencode/src/toast.ts b/packages/opencode/src/toast.ts index 285d7c6..b2d3e4e 100644 --- a/packages/opencode/src/toast.ts +++ b/packages/opencode/src/toast.ts @@ -51,12 +51,14 @@ export function hasLegacyToast( export function sendToast( client: ToastCapableClient, payload: ToastPayload, -): void | Promise { +): Promise { if (hasShowToast(client)) { - return client.tui.showToast({ body: payload }); + return Promise.resolve(client.tui.showToast({ body: payload })); } if (hasLegacyToast(client)) { - return client.tui.toast(payload); + return Promise.resolve(client.tui.toast(payload)); } + + return Promise.resolve(); } diff --git a/packages/opencode/tests/brief-intent.test.ts b/packages/opencode/tests/brief-intent.test.ts index 71257e5..4b1a15e 100644 --- a/packages/opencode/tests/brief-intent.test.ts +++ b/packages/opencode/tests/brief-intent.test.ts @@ -25,7 +25,6 @@ type BriefIntentResult = { fingerprint: string; sourceFiles: string[]; seedIds: string[]; - keepManualCue: boolean; }; type BriefIntentModule = { @@ -117,7 +116,7 @@ describe("deriveBriefIntent", () => { assert.equal(result.eligible, true); assert.equal(result.reason, "Eligible for auto-briefing"); - assert.equal(result.keepManualCue, true); + assert.equal(Object.prototype.hasOwnProperty.call(result, "keepManualCue"), false); assert.deepEqual(result.sourceFiles, ["/workspace/src/foo.ts"]); assert.deepEqual(result.seedIds, []); }); @@ -245,10 +244,10 @@ describe("deriveBriefIntent", () => { ); }); - test("keeps keepManualCue true even when result is ineligible", async () => { + test("does not expose keepManualCue even when result is ineligible", async () => { const result = await derive({ posture: "vendored_only" }); - assert.equal(result.keepManualCue, true); + assert.equal(Object.prototype.hasOwnProperty.call(result, "keepManualCue"), false); }); test("uses pre-fetched seedIds directly and truncates to three", async () => { diff --git a/packages/opencode/tests/briefing-auto-render.test.ts b/packages/opencode/tests/briefing-auto-render.test.ts index 1f2992d..7b9c7aa 100644 --- a/packages/opencode/tests/briefing-auto-render.test.ts +++ b/packages/opencode/tests/briefing-auto-render.test.ts @@ -135,9 +135,8 @@ function makeIntent( `brief:${workspaceCtx.workspaceRoot}\0${workspaceCtx.branch}\0${editedFilePath}\0behavior_candidate`, sourceFiles: overrides.sourceFiles ?? [editedFilePath], seedIds: overrides.seedIds ?? ["REQ-001"], - keepManualCue: overrides.keepManualCue ?? true, ...overrides, - }; + } as BriefIntentResult; } function promptResponseFromJson(value: unknown): unknown { diff --git a/packages/opencode/tests/index.test.ts b/packages/opencode/tests/index.test.ts index 73e5105..e27f844 100644 --- a/packages/opencode/tests/index.test.ts +++ b/packages/opencode/tests/index.test.ts @@ -17,6 +17,7 @@ import kibiOpencodePlugin from "../src/index"; import * as briefingRuntimeModule from "../src/briefing-runtime"; import * as logger from "../src/logger"; import * as promptModule from "../src/prompt"; +import * as toastModule from "../src/toast"; import type { PluginInput } from "../src/index"; import { runPluginStartup } from "../src/plugin-startup"; import { getSessionTracker, resetSessionTracker } from "../src/session-tracker"; @@ -3437,6 +3438,73 @@ import datetime }); }); + it("treats auto-brief toast delivery failure as non-fatal", async () => { + setupAuthoritativeWorkspace(tmpDir); + installNoopScheduler(tmpDir); + writePluginConfig(tmpDir, { + enabled: true, + prompt: { enabled: true, hookMode: "auto" }, + sync: { enabled: true }, + ux: { toastStartup: false }, + guidance: { + commentDetection: { enabled: false }, + smartEnforcement: { + completionReminder: false, + }, + }, + }); + + const srcDir = path.join(tmpDir, "src"); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync( + path.join(srcDir, "feature.ts"), + "export function feature() { return 42; } // implements REQ-001\n", + ); + + const { client } = createAutoBriefClient(); + const unhandledRejections: unknown[] = []; + const handleUnhandledRejection = (reason: unknown) => { + unhandledRejections.push(reason); + }; + const fetchSpy = spyOn(briefingRuntimeModule, "fetchBriefingResult"); + const sendToastSpy = spyOn(toastModule, "sendToast").mockImplementation(() => + Promise.reject(new Error("toast failed")), + ); + process.on("unhandledRejection", handleUnhandledRejection); + + try { + const plugin = await loadFreshPlugin(); + const hooks = await plugin(makeInput({ client })); + + assert.ok(hooks.event); + const eventHook = hooks.event as (input: { + event: { type: string; properties: { file: string } }; + }) => Promise; + + await eventHook({ + event: { + type: "file.edited", + properties: { file: "src/feature.ts" }, + }, + }); + await waitForCondition( + () => fetchSpy.mock.calls.length === 1 && sendToastSpy.mock.calls.length === 1, + ); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(fetchSpy.mock.calls.length, 1); + assert.equal(sendToastSpy.mock.calls.length, 1); + assert.equal( + unhandledRejections.length, + 0, + "Toast delivery failures should be caught and stay non-fatal", + ); + } finally { + process.off("unhandledRejection", handleUnhandledRejection); + } + }); + it("sends exactly one toast for repeated same-fingerprint edit events", async () => { setupAuthoritativeWorkspace(tmpDir); installNoopScheduler(tmpDir); @@ -3871,6 +3939,50 @@ import datetime }, }); await Promise.resolve(); + assert.equal(fetchSpy.mock.calls.length, 0); + + const testsDir = path.join(tmpDir, "tests"); + fs.mkdirSync(testsDir, { recursive: true }); + fs.writeFileSync( + path.join(testsDir, "feature.test.ts"), + "import { test, expect } from 'bun:test';\ntest('safe', () => expect(true).toBe(true));\n", + ); + const { client: safeTestClient } = createAutoBriefClient(); + const safeTestPlugin = await loadFreshPlugin(); + const safeTestHooks = await safeTestPlugin(makeInput({ client: safeTestClient })); + assert.ok(safeTestHooks.event); + + const safeTestEventHook = safeTestHooks.event as (input: { + event: { type: string; properties: { file: string } }; + }) => Promise; + await safeTestEventHook({ + event: { + type: "file.edited", + properties: { file: "tests/feature.test.ts" }, + }, + }); + await Promise.resolve(); + assert.equal(fetchSpy.mock.calls.length, 0); + + const kbDir = path.join(tmpDir, ".kb"); + fs.mkdirSync(kbDir, { recursive: true }); + fs.writeFileSync(path.join(kbDir, "manual-edit.json"), "{}\n"); + const { client: manualKbClient } = createAutoBriefClient(); + const manualKbPlugin = await loadFreshPlugin(); + const manualKbHooks = await manualKbPlugin(makeInput({ client: manualKbClient })); + assert.ok(manualKbHooks.event); + + const manualKbEventHook = manualKbHooks.event as (input: { + event: { type: string; properties: { file: string } }; + }) => Promise; + await manualKbEventHook({ + event: { + type: "file.edited", + properties: { file: ".kb/manual-edit.json" }, + }, + }); + await Promise.resolve(); + assert.equal(fetchSpy.mock.calls.length, 0); writePluginConfig(tmpDir, { enabled: true, diff --git a/packages/opencode/tests/prompt.test.ts b/packages/opencode/tests/prompt.test.ts index 6013fdf..e3903c0 100644 --- a/packages/opencode/tests/prompt.test.ts +++ b/packages/opencode/tests/prompt.test.ts @@ -48,12 +48,16 @@ const baseConfig: KibiConfig = { function makeAutoBriefResult( overrides: Partial = {}, ): BriefingRuntimeResult { + const state = overrides.state ?? "ready"; + const promptBlock = overrides.promptBlock ?? "- REQ-001: Auto summary"; + return { - state: "ready", - promptBlock: "- REQ-001: Auto summary", - tldr: "Auto summary", - citations: [], - showManualCue: true, + state, + promptBlock, + tldr: overrides.tldr ?? "Auto summary", + citations: overrides.citations ?? [], + showManualCue: + overrides.showManualCue ?? !(state === "ready" && promptBlock.trim() !== ""), toastMessage: "Kibi brief ready β€” summary added to guidance.", ...overrides, }; @@ -1131,6 +1135,27 @@ describe("auto-brief prompt rendering", () => { ); }); + test("ready-state auto-brief honors showManualCue when deciding whether to suppress /brief-kibi", () => { + const briefKibiCue = + "Authoritative risky edit: run `/brief-kibi` before acting."; + const p = buildRiskyPrompt({ + autoBriefResult: makeAutoBriefResult({ + state: "ready", + promptBlock: "- REQ-001: Session timeout", + showManualCue: true, + }), + }); + + assert.ok( + p.includes("🧠 **Kibi briefing available**"), + "Should still render the auto-brief header", + ); + assert.ok( + p.includes(briefKibiCue), + "Should preserve /brief-kibi cue when showManualCue requests it", + ); + }); + test("ready-state auto-brief suppresses source-linked micro-brief insertion", () => { writeSymbolsYamlForPrompt(); @@ -1582,6 +1607,41 @@ describe("source-linked micro-brief contract", () => { assert.ok(bullets <= 5, `Expected <= 5 bullets, got ${bullets}`); }); + test("traceability guidance with source-linked brief and reminder stays within 5 bullets", () => { + const reminderText = "Run `kb_check` before completing this task."; + const briefKibiCue = + "Authoritative risky edit: run `/brief-kibi` before acting."; + + writeSymbolsYaml([ + { + id: "SYM-buildPrompt", + sourceFile: "packages/opencode/src/prompt.ts", + links: ["REQ-opencode-smart-enforcement-v1"], + }, + ]); + + const p = buildPrompt({ + recentEdits: [{ path: "packages/opencode/src/prompt.ts", kind: "code" }], + posture: "root_active", + riskClass: "traceability_candidate", + completionReminder: true, + workspaceRoot: tmpDir, + }); + + const bullets = p + .split("\n") + .filter((line) => line.trimStart().startsWith("-")); + + assert.ok(p.includes("- Existing Kibi links:"), "Should include source-linked brief"); + assert.ok(p.includes(briefKibiCue), "Should include /brief-kibi cue"); + assert.ok(p.includes(reminderText), "Should include completion reminder"); + assert.equal( + bullets.length, + 5, + "Traceability guidance plus reminder should stay within the 5-bullet cap", + ); + }); + test("cache behavior remains intact with source-linked brief", () => { writeSymbolsYaml([ { From a53c9aca694989144ee7f97cccff659ce3719bdd Mon Sep 17 00:00:00 2001 From: Piotr Franczyk Date: Thu, 23 Apr 2026 16:24:58 +0200 Subject: [PATCH 14/17] Version bump, added opencode briefing automation --- .changeset/kibi-briefings-v1.md | 8 -------- .changeset/kibi-opencode-auto-brief-v2.md | 5 ----- packages/mcp/CHANGELOG.md | 8 ++++++++ packages/mcp/package.json | 2 +- packages/opencode/CHANGELOG.md | 10 ++++++++++ packages/opencode/package.json | 2 +- 6 files changed, 20 insertions(+), 15 deletions(-) delete mode 100644 .changeset/kibi-briefings-v1.md delete mode 100644 .changeset/kibi-opencode-auto-brief-v2.md diff --git a/.changeset/kibi-briefings-v1.md b/.changeset/kibi-briefings-v1.md deleted file mode 100644 index 41cd8e8..0000000 --- a/.changeset/kibi-briefings-v1.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"kibi-mcp": minor -"kibi-opencode": minor ---- - -Kibi can now generate citation-backed start-task briefings through MCP with `kb_briefing_generate`, making it easier for agents to begin risky work from source-linked project context. - -OpenCode now surfaces that workflow through `/brief-kibi`, so teams can trigger the same Kibi briefing path directly from the editor before acting. diff --git a/.changeset/kibi-opencode-auto-brief-v2.md b/.changeset/kibi-opencode-auto-brief-v2.md deleted file mode 100644 index 8c91012..0000000 --- a/.changeset/kibi-opencode-auto-brief-v2.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kibi-opencode": minor ---- - -OpenCode now auto-fetches Kibi briefings from the event path when authoritative risky edits are detected. Ready-state briefings appear in a toast notification and inside the agent guidance block (headed `🧠 **Kibi briefing available**`). The `/brief-kibi` manual command remains available as a fallback. diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index dd58dac..65e8360 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,13 @@ # kibi-mcp +## 0.10.0 + +### Minor Changes + +- 2bd0804: Kibi can now generate citation-backed start-task briefings through MCP with `kb_briefing_generate`, making it easier for agents to begin risky work from source-linked project context. + + OpenCode now surfaces that workflow through `/brief-kibi`, so teams can trigger the same Kibi briefing path directly from the editor before acting. + ## 0.9.0 ### Minor Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 85d8a95..81d4d42 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "kibi-mcp", - "version": "0.9.0", + "version": "0.10.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", "ajv": "^8.18.0", diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index 1dbcf57..85926e4 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -1,5 +1,15 @@ # kibi-opencode +## 0.9.0 + +### Minor Changes + +- 2bd0804: Kibi can now generate citation-backed start-task briefings through MCP with `kb_briefing_generate`, making it easier for agents to begin risky work from source-linked project context. + + OpenCode now surfaces that workflow through `/brief-kibi`, so teams can trigger the same Kibi briefing path directly from the editor before acting. + +- f9258c6: OpenCode now auto-fetches Kibi briefings from the event path when authoritative risky edits are detected. Ready-state briefings appear in a toast notification and inside the agent guidance block (headed `🧠 **Kibi briefing available**`). The `/brief-kibi` manual command remains available as a fallback. + ## 0.8.0 ### Minor Changes diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 27fdcff..501d2d9 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "name": "kibi-opencode", - "version": "0.8.0", + "version": "0.9.0", "description": "Kibi OpenCode plugin - thin adapter to integrate Kibi with OpenCode sessions", "type": "module", "main": "dist/index.js", From b8ef3374d47e195559fa498bd4122b281b049d1e Mon Sep 17 00:00:00 2001 From: Piotr Franczyk Date: Thu, 23 Apr 2026 16:55:05 +0200 Subject: [PATCH 15/17] docs(kb): fix invalid relationship types in v2 scenario, test, and symbol docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace schema-invalid supersedes edges (scenarioβ†’scenario, testβ†’test) with relates_to in SCEN-opencode-kibi-briefing-v2 and TEST-opencode-kibi-briefing-v2. Remove plain string links arrays from SYM-cli-status-pre-first-sync-test and SYM-mcp-cli-help-test that were being mis-imported as invalid implements edges; executable_for relationships are preserved. --- .../SCEN-opencode-kibi-briefing-v2.md | 4 +- documentation/symbols.yaml | 50 +++++++++---------- .../tests/TEST-opencode-kibi-briefing-v2.md | 4 +- 3 files changed, 27 insertions(+), 31 deletions(-) diff --git a/documentation/scenarios/SCEN-opencode-kibi-briefing-v2.md b/documentation/scenarios/SCEN-opencode-kibi-briefing-v2.md index 1e51eb9..f52af11 100644 --- a/documentation/scenarios/SCEN-opencode-kibi-briefing-v2.md +++ b/documentation/scenarios/SCEN-opencode-kibi-briefing-v2.md @@ -3,7 +3,7 @@ id: SCEN-opencode-kibi-briefing-v2 title: "OpenCode Kibi Briefing v2: Auto-Show and Fallback Behaviors" status: draft created_at: 2026-04-23T00:00:00Z -updated_at: 2026-04-23T00:00:00Z +updated_at: 2026-04-23T14:52:50Z source: documentation/scenarios/SCEN-opencode-kibi-briefing-v2.md tags: - scenario @@ -13,7 +13,7 @@ tags: links: - type: relates_to target: REQ-opencode-kibi-briefing-v2 - - type: supersedes + - type: relates_to target: SCEN-opencode-kibi-briefing-v1 --- diff --git a/documentation/symbols.yaml b/documentation/symbols.yaml index f735505..b105350 100644 --- a/documentation/symbols.yaml +++ b/documentation/symbols.yaml @@ -22,7 +22,7 @@ symbols: sourceColumn: 13 sourceEndLine: 588 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:27.653Z' + coordinatesGeneratedAt: '2026-04-23T14:54:36.676Z' - id: SYM-002 title: handleKbUpsert sourceFile: packages/mcp/src/tools/upsert.ts @@ -40,7 +40,7 @@ symbols: sourceColumn: 22 sourceEndLine: 247 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:27.871Z' + coordinatesGeneratedAt: '2026-04-23T14:54:36.968Z' - id: SYM-003 title: handleKbQuery sourceFile: packages/mcp/src/tools/query.ts @@ -55,7 +55,7 @@ symbols: sourceColumn: 22 sourceEndLine: 97 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:27.882Z' + coordinatesGeneratedAt: '2026-04-23T14:54:36.972Z' - id: SYM-004 title: handleKbCheck sourceFile: packages/mcp/src/tools/check.ts @@ -73,7 +73,7 @@ symbols: sourceColumn: 22 sourceEndLine: 216 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:28.052Z' + coordinatesGeneratedAt: '2026-04-23T14:54:37.118Z' - id: SYM-005 title: KibiTreeDataProvider sourceFile: packages/vscode/src/treeProvider.ts @@ -91,7 +91,7 @@ symbols: sourceColumn: 13 sourceEndLine: 967 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:28.333Z' + coordinatesGeneratedAt: '2026-04-23T14:54:37.345Z' - id: SYM-007 title: extractFromManifest sourceFile: packages/cli/src/extractors/manifest.ts @@ -106,7 +106,7 @@ symbols: sourceColumn: 16 sourceEndLine: 197 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:28.460Z' + coordinatesGeneratedAt: '2026-04-23T14:54:37.434Z' - id: SYM-010 title: startServer sourceFile: packages/mcp/src/server.ts @@ -121,7 +121,7 @@ symbols: sourceColumn: 22 sourceEndLine: 57 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:29.067Z' + coordinatesGeneratedAt: '2026-04-23T14:54:37.808Z' - id: SYM-KibiTreeDataProvider title: KibiTreeDataProvider sourceFile: packages/vscode/src/treeProvider.ts @@ -139,7 +139,7 @@ symbols: sourceColumn: 13 sourceEndLine: 967 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:29.073Z' + coordinatesGeneratedAt: '2026-04-23T14:54:37.814Z' - id: SYM-KibiCodeActionProvider title: KibiCodeActionProvider sourceFile: packages/vscode/src/codeActionProvider.ts @@ -156,7 +156,7 @@ symbols: sourceColumn: 13 sourceEndLine: 106 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:29.076Z' + coordinatesGeneratedAt: '2026-04-23T14:54:37.816Z' - id: SYM-handleKbQueryRelationships title: handleKbQueryRelationships sourceFile: packages/mcp/src/tools/query-relationships.ts @@ -192,7 +192,7 @@ symbols: sourceColumn: 16 sourceEndLine: 91 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:29.077Z' + coordinatesGeneratedAt: '2026-04-23T14:54:37.817Z' - id: SYM-KibiCodeLensProvider title: KibiCodeLensProvider sourceFile: packages/vscode/src/codeLensProvider.ts @@ -209,7 +209,7 @@ symbols: sourceColumn: 13 sourceEndLine: 338 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:29.263Z' + coordinatesGeneratedAt: '2026-04-23T14:54:37.950Z' - id: SYM-mergeStaticLinks title: mergeStaticLinks sourceFile: packages/vscode/src/codeLensProvider.ts @@ -224,7 +224,7 @@ symbols: sourceColumn: 10 sourceEndLine: 214 sourceEndColumn: 3 - coordinatesGeneratedAt: '2026-04-23T08:34:29.266Z' + coordinatesGeneratedAt: '2026-04-23T14:54:37.951Z' - id: SYM-parseSymbolsManifest title: parseSymbolsManifest sourceFile: packages/vscode/src/symbolIndex.ts @@ -241,7 +241,7 @@ symbols: sourceColumn: 9 sourceEndLine: 197 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:29.266Z' + coordinatesGeneratedAt: '2026-04-23T14:54:37.952Z' - id: SYM-getKbExistenceTargets title: getKbExistenceTargets sourceFile: packages/opencode/src/file-filter.ts @@ -256,7 +256,7 @@ symbols: sourceColumn: 16 sourceEndLine: 102 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:29.453Z' + coordinatesGeneratedAt: '2026-04-23T14:54:38.071Z' - id: SYM-checkWorkspaceHealth title: checkWorkspaceHealth sourceFile: packages/opencode/src/workspace-health.ts @@ -271,7 +271,7 @@ symbols: sourceColumn: 16 sourceEndLine: 96 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:29.604Z' + coordinatesGeneratedAt: '2026-04-23T14:54:38.206Z' - id: SYM-detectPosture title: detectPosture sourceFile: packages/opencode/src/repo-posture.ts @@ -289,7 +289,7 @@ symbols: sourceColumn: 16 sourceEndLine: 241 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:29.607Z' + coordinatesGeneratedAt: '2026-04-23T14:54:38.210Z' - id: SYM-classifyRisk title: classifyRisk sourceFile: packages/opencode/src/risk-classifier.ts @@ -307,7 +307,7 @@ symbols: sourceColumn: 16 sourceEndLine: 175 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:29.749Z' + coordinatesGeneratedAt: '2026-04-23T14:54:38.326Z' - id: SYM-GuidanceCache title: GuidanceCache sourceFile: packages/opencode/src/guidance-cache.ts @@ -325,7 +325,7 @@ symbols: sourceColumn: 13 sourceEndLine: 162 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:29.885Z' + coordinatesGeneratedAt: '2026-04-23T14:54:38.439Z' - id: SYM-buildPrompt title: buildPrompt sourceFile: packages/opencode/src/prompt.ts @@ -344,11 +344,11 @@ symbols: target: TEST-opencode-smart-enforcement - type: covered_by target: TEST-opencode-agent-mcp-only - sourceLine: 479 + sourceLine: 551 sourceColumn: 16 - sourceEndLine: 484 + sourceEndLine: 556 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:30.085Z' + coordinatesGeneratedAt: '2026-04-23T14:54:38.599Z' - id: SYM-parseRdfRelationships title: parseRdfRelationships sourceFile: packages/vscode/src/shared/rdf-parser.ts @@ -361,7 +361,7 @@ symbols: sourceColumn: 16 sourceEndLine: 67 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:30.085Z' + coordinatesGeneratedAt: '2026-04-23T14:54:38.600Z' - id: SYM-KB_RELATIONSHIP_TYPES title: KB_RELATIONSHIP_TYPES sourceFile: packages/vscode/src/shared/rdf-parser.ts @@ -374,7 +374,7 @@ symbols: sourceColumn: 13 sourceEndLine: 28 sourceEndColumn: 1 - coordinatesGeneratedAt: '2026-04-23T08:34:30.086Z' + coordinatesGeneratedAt: '2026-04-23T14:54:38.600Z' - id: SYM-kb-status-json title: kb_status/0 (JSON) sourceFile: packages/core/src/status.pl @@ -406,16 +406,12 @@ symbols: - id: SYM-cli-status-pre-first-sync-test title: cli status pre-sync test sourceFile: packages/cli/tests/commands/status.test.ts - links: - - TEST-cli-status-pre-first-sync relationships: - type: executable_for target: TEST-cli-status-pre-first-sync - id: SYM-mcp-cli-help-test title: mcp cli help test sourceFile: packages/mcp/tests/cli-help.test.ts - links: - - TEST-mcp-cli-help relationships: - type: executable_for target: TEST-mcp-cli-help diff --git a/documentation/tests/TEST-opencode-kibi-briefing-v2.md b/documentation/tests/TEST-opencode-kibi-briefing-v2.md index 2dca454..277b0ba 100644 --- a/documentation/tests/TEST-opencode-kibi-briefing-v2.md +++ b/documentation/tests/TEST-opencode-kibi-briefing-v2.md @@ -3,7 +3,7 @@ id: TEST-opencode-kibi-briefing-v2 title: "OpenCode Kibi Briefings v2 Verification" status: pending created_at: 2026-04-23T00:00:00Z -updated_at: 2026-04-23T00:00:00Z +updated_at: 2026-04-23T14:52:50Z source: documentation/tests/TEST-opencode-kibi-briefing-v2.md priority: must tags: @@ -14,7 +14,7 @@ tags: links: - type: validates target: SCEN-opencode-kibi-briefing-v2 - - type: supersedes + - type: relates_to target: TEST-opencode-kibi-briefing-v1 --- From d3c2e437cbd5ac502dfd88dbd01e56325d0bdd9f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:44:11 +0000 Subject: [PATCH 16/17] Initial plan From de6db8431ecbe4c1217ed8ea48477b7a93c5889b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:48:25 +0000 Subject: [PATCH 17/17] fix: apply PR review feedback for auto-briefing v2 - brief-intent.ts: normalize editedFilePath to absolute path before passing to getSourceLinkedRequirementIds() to fix silent [] return when file.edited emits a relative path - index.ts: cache lastBriefFingerprint from file.edited handler; system.transform now looks up the cached fingerprint instead of recomputing computeBriefIntent() (avoids repeated symbols.yaml I/O) - Remove accidental #YT|> annotation prefixes from REQ, SCEN, and TEST v1 documentation markdown files Agent-Logs-Url: https://github.com/Looted/kibi/sessions/48b5f91e-68ec-4a84-98ad-f37766084caf Co-authored-by: Looted <6255880+Looted@users.noreply.github.com> --- .../REQ-opencode-kibi-briefing-v1.md | 4 ++-- .../SCEN-opencode-kibi-briefing-v1.md | 4 ++-- .../tests/TEST-opencode-kibi-briefing-v1.md | 4 ++-- packages/opencode/src/brief-intent.ts | 7 +++++- packages/opencode/src/index.ts | 22 +++++-------------- 5 files changed, 18 insertions(+), 23 deletions(-) diff --git a/documentation/requirements/REQ-opencode-kibi-briefing-v1.md b/documentation/requirements/REQ-opencode-kibi-briefing-v1.md index fdf0b99..afb2886 100644 --- a/documentation/requirements/REQ-opencode-kibi-briefing-v1.md +++ b/documentation/requirements/REQ-opencode-kibi-briefing-v1.md @@ -29,8 +29,8 @@ links: --- -31#YT|> **Note**: This requirement is DEPRECATED and superseded by REQ-opencode-kibi-briefing-v2. -32#YT|> It remains here for historical context and to document the v1 cue-driven contract. +> **Note**: This requirement is DEPRECATED and superseded by REQ-opencode-kibi-briefing-v2. +> It remains here for historical context and to document the v1 cue-driven contract. The OpenCode briefing experience must expose Kibi Briefings v1 as a sanctioned, cue-driven start-task workflow rather than an automatic runtime fetch. 1. **Sanctioned Command**: `/brief-kibi` must be the sanctioned start-task command for requesting a Kibi briefing in OpenCode. diff --git a/documentation/scenarios/SCEN-opencode-kibi-briefing-v1.md b/documentation/scenarios/SCEN-opencode-kibi-briefing-v1.md index 5acda59..ffdd938 100644 --- a/documentation/scenarios/SCEN-opencode-kibi-briefing-v1.md +++ b/documentation/scenarios/SCEN-opencode-kibi-briefing-v1.md @@ -18,8 +18,8 @@ links: --- -20#YT|> **Note**: This scenario is DEPRECATED and superseded by SCEN-opencode-kibi-briefing-v2. -21#YT|> It documents the historical v1 cue-driven behavior. +> **Note**: This scenario is DEPRECATED and superseded by SCEN-opencode-kibi-briefing-v2. +> It documents the historical v1 cue-driven behavior. **Scenario: Authoritative risky edit gets a start-task cue** **GIVEN** an OpenCode session is in an authoritative, non-degraded posture diff --git a/documentation/tests/TEST-opencode-kibi-briefing-v1.md b/documentation/tests/TEST-opencode-kibi-briefing-v1.md index 4f948f5..27e33ed 100644 --- a/documentation/tests/TEST-opencode-kibi-briefing-v1.md +++ b/documentation/tests/TEST-opencode-kibi-briefing-v1.md @@ -17,8 +17,8 @@ links: --- -19#YT|> **Note**: This test doc is DEPRECATED and superseded by TEST-opencode-kibi-briefing-v2. -20#YT|> Historical verification for v1 cue-driven briefings remains documented below. +> **Note**: This test doc is DEPRECATED and superseded by TEST-opencode-kibi-briefing-v2. +> Historical verification for v1 cue-driven briefings remains documented below. Automated verification for the OpenCode Kibi Briefings v1 contract includes: 1. **Sanctioned Command Policy Test**: Verify that agent-facing OpenCode guidance treats `/brief-kibi` as a sanctioned start-task command. diff --git a/packages/opencode/src/brief-intent.ts b/packages/opencode/src/brief-intent.ts index 8780122..238a9dc 100644 --- a/packages/opencode/src/brief-intent.ts +++ b/packages/opencode/src/brief-intent.ts @@ -1,5 +1,6 @@ // implements REQ-opencode-kibi-briefing-v2, REQ-opencode-smart-enforcement-v1 +import * as path from "node:path"; import type { RepoPosture } from "./repo-posture.js"; import type { RiskClass } from "./risk-classifier.js"; import { getSourceLinkedRequirementIds } from "./source-linked-guidance.js"; @@ -55,9 +56,13 @@ function deriveSeedIds(params: BriefIntentParams): string[] { return params.seedIds.slice(0, 3); } + const absoluteEditedPath = path.isAbsolute(params.editedFilePath) + ? params.editedFilePath + : path.join(params.workspaceRoot, params.editedFilePath); + return getSourceLinkedRequirementIds( params.workspaceRoot, - params.editedFilePath, + absoluteEditedPath, ).slice(0, 3); } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index df808f4..89073ed 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -168,6 +168,7 @@ const kibiOpencodePlugin: Plugin = async ( const toastedFingerprints = new Set(); let lastRiskClass: RiskClass | null = null; let lastEditedFilePath: string | null = null; + let lastBriefFingerprint: string | null = null; let degradedWarnedOnce = false; hooks.event = async ({ event }) => { @@ -519,6 +520,8 @@ const kibiOpencodePlugin: Plugin = async ( branch: currentBranch, }); + lastBriefFingerprint = intentResult.fingerprint; + if ( intentResult.eligible && input.client && @@ -565,22 +568,9 @@ const kibiOpencodePlugin: Plugin = async ( maintenanceDegraded && cfg.guidance.smartEnforcement.degradedMode === "warn-once" && !degradedWarnedOnce; - const autoBriefResult = (() => { - if (lastRiskClass == null || lastEditedFilePath == null) { - return undefined; - } - - const intentResult = computeBriefIntent({ - riskClass: lastRiskClass, - posture: posture.state, - maintenanceDegraded, - editedFile: lastEditedFilePath, - worktreeRoot: input.worktree, - branch: currentBranch, - }); - - return autoBriefResults.get(intentResult.fingerprint); - })(); + const autoBriefResult = lastBriefFingerprint != null + ? autoBriefResults.get(lastBriefFingerprint) + : undefined; // Build only the guidance block and append it; existing entries are preserved const guidance = buildPrompt({