diff --git a/.changeset/kibi-briefings-v1.md b/.changeset/kibi-briefings-v1.md deleted file mode 100644 index 41cd8e83..00000000 --- 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/documentation/requirements/REQ-opencode-kibi-briefing-v1.md b/documentation/requirements/REQ-opencode-kibi-briefing-v1.md index 07b95d12..afb28866 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 --- + +> **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/requirements/REQ-opencode-kibi-briefing-v2.md b/documentation/requirements/REQ-opencode-kibi-briefing-v2.md new file mode 100644 index 00000000..ba0d9da5 --- /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 0bdf1917..ffdd938a 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 --- + +> **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/scenarios/SCEN-opencode-kibi-briefing-v2.md b/documentation/scenarios/SCEN-opencode-kibi-briefing-v2.md new file mode 100644 index 00000000..f52af113 --- /dev/null +++ b/documentation/scenarios/SCEN-opencode-kibi-briefing-v2.md @@ -0,0 +1,52 @@ +--- +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-23T14:52:50Z +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: relates_to + 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. + +**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** + +**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 3841ae79..b1053509 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-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-22T07:28:29.867Z' + 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-22T07:28:29.878Z' + 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-22T07:28:30.055Z' + 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-22T07:28:30.355Z' + 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-22T07:28:30.462Z' + 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-22T07:28:30.871Z' + 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-22T07:28:30.877Z' + 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-22T07:28:30.880Z' + 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-22T07:28:30.881Z' + 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-22T07:28:31.037Z' + 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-22T07:28:31.039Z' + 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-22T07:28:31.040Z' + 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-22T07:28:31.192Z' + 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-22T07:28:31.411Z' + 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-22T07:28:31.415Z' + 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-22T07:28:31.572Z' + 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-22T07:28:31.768Z' + 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-22T07:28:31.951Z' + 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-22T07:28:31.951Z' + 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-22T07:28:31.952Z' + 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-v1.md b/documentation/tests/TEST-opencode-kibi-briefing-v1.md index b1f645ce..27e33ed7 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 --- + +> **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/documentation/tests/TEST-opencode-kibi-briefing-v2.md b/documentation/tests/TEST-opencode-kibi-briefing-v2.md new file mode 100644 index 00000000..277b0ba7 --- /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-23T14:52:50Z +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: relates_to + 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/CHANGELOG.md b/packages/mcp/CHANGELOG.md index dd58dac0..65e83608 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 85d8a959..81d4d428 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/mcp/tests/tools/briefing-generate.test.ts b/packages/mcp/tests/tools/briefing-generate.test.ts index c59a580c..30d710ec 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/CHANGELOG.md b/packages/opencode/CHANGELOG.md index 1dbcf570..85926e4b 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/README.md b/packages/opencode/README.md index 536635d8..502eaf6f 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 diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 27fdcffd..501d2d9f 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", diff --git a/packages/opencode/src/brief-intent.ts b/packages/opencode/src/brief-intent.ts new file mode 100644 index 00000000..238a9dc7 --- /dev/null +++ b/packages/opencode/src/brief-intent.ts @@ -0,0 +1,140 @@ +// 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"; + +const ELIGIBLE_RISK_CLASSES: ReadonlySet = new Set([ + "behavior_candidate", + "traceability_candidate", +]); + +const STRICT_ELIGIBLE_POSTURES: ReadonlySet = new Set([ + "root_active", + "hybrid_root_plus_vendored", +]); + +export interface BriefIntentParams { + riskClass: RiskClass; + posture: RepoPosture; + maintenanceDegraded: boolean; + workspaceRoot: string; + branch: string; + editedFilePath: string | undefined; + seedIds?: string[]; +} + +export interface BriefIntentResult { + eligible: boolean; + reason: string; + fingerprint: string; + sourceFiles: string[]; + seedIds: string[]; +} + +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; +} + +function deriveSeedIds(params: BriefIntentParams): string[] { + if (!hasEditedFilePath(params.editedFilePath)) { + return []; + } + + if (params.seedIds !== undefined) { + return params.seedIds.slice(0, 3); + } + + const absoluteEditedPath = path.isAbsolute(params.editedFilePath) + ? params.editedFilePath + : path.join(params.workspaceRoot, params.editedFilePath); + + return getSourceLinkedRequirementIds( + params.workspaceRoot, + absoluteEditedPath, + ).slice(0, 3); +} + +// 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, + }; + } + + if (!ELIGIBLE_RISK_CLASSES.has(params.riskClass)) { + return { + eligible: false, + reason: `Ineligible: riskClass ${params.riskClass} is not auto-brief eligible`, + fingerprint, + sourceFiles, + seedIds, + }; + } + + if (!STRICT_ELIGIBLE_POSTURES.has(params.posture)) { + return { + eligible: false, + reason: `Ineligible: posture ${params.posture} is not authoritative`, + fingerprint, + sourceFiles, + seedIds, + }; + } + + if (params.maintenanceDegraded) { + return { + eligible: false, + reason: "Ineligible: maintenance is degraded", + fingerprint, + sourceFiles, + seedIds, + }; + } + + return { + eligible: true, + reason: "Eligible for auto-briefing", + fingerprint, + sourceFiles, + seedIds, + }; +} + +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/briefing-runtime.ts b/packages/opencode/src/briefing-runtime.ts new file mode 100644 index 00000000..8bea2672 --- /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/src/index.ts b/packages/opencode/src/index.ts index e3a105be..89073ed5 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,11 @@ const kibiOpencodePlugin: Plugin = async ( let hasRecentKbEdit = false; 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 lastBriefFingerprint: string | null = null; let degradedWarnedOnce = false; hooks.event = async ({ event }) => { @@ -205,7 +217,11 @@ const kibiOpencodePlugin: Plugin = async ( riskClass === "safe_docs_only" && precomputedSuggestion ? "traceability_candidate" : riskClass; + const isAutoBriefRisk = + effectiveRiskClass === "behavior_candidate" || + effectiveRiskClass === "traceability_candidate"; lastRiskClass = effectiveRiskClass; + lastEditedFilePath = filePath; logger.info("smart-enforcement.risk", { event: "smart_enforcement_risk", @@ -347,7 +363,9 @@ const kibiOpencodePlugin: Plugin = async ( posture: posture.state, posture_state: posture.state, }); - return; + if (!isAutoBriefRisk) { + return; + } } logger.info("smart-enforcement.cache", { @@ -456,10 +474,7 @@ const kibiOpencodePlugin: Plugin = async ( return; } - if ( - effectiveRiskClass === "behavior_candidate" || - effectiveRiskClass === "traceability_candidate" - ) { + if (isAutoBriefRisk) { if ( pathAnalysis.kind === "code" && cfg.guidance.commentDetection.enabled @@ -495,6 +510,44 @@ const kibiOpencodePlugin: Plugin = async ( } else { recentCommentSuggestion = null; } + + const intentResult = computeBriefIntent({ + riskClass: effectiveRiskClass, + posture: posture.state, + maintenanceDegraded: getMaintenanceDegraded(), + editedFile: filePath, + worktreeRoot: input.worktree, + branch: currentBranch, + }); + + lastBriefFingerprint = intentResult.fingerprint; + + if ( + intentResult.eligible && + input.client && + !getMaintenanceDegraded() && + (posture.state === "root_active" || + posture.state === "hybrid_root_plus_vendored") + ) { + const client = input.client; + const fingerprint = intentResult.fingerprint; + 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(fingerprint, result); + if (!toastedFingerprints.has(fingerprint)) { + toastedFingerprints.add(fingerprint); + void sendToast(client, { message: result.toastMessage }).catch(() => { + // toast delivery failure is non-fatal + }); + } + }); + } } return; @@ -515,6 +568,9 @@ const kibiOpencodePlugin: Plugin = async ( maintenanceDegraded && cfg.guidance.smartEnforcement.degradedMode === "warn-once" && !degradedWarnedOnce; + const autoBriefResult = lastBriefFingerprint != null + ? autoBriefResults.get(lastBriefFingerprint) + : undefined; // Build only the guidance block and append it; existing entries are preserved const guidance = buildPrompt({ @@ -530,6 +586,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 9052c88c..c1c53432 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"; @@ -14,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", @@ -29,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); @@ -57,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); } @@ -92,6 +128,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 ────────────────────────────────────── @@ -168,6 +206,11 @@ 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?.showManualCue === false; + const suppressSourceLinkedBrief = + context.autoBriefResult?.state === "ready" || + context.autoBriefResult?.state === "tldr_fallback"; const showDegraded = context.showDegradedAdvisory === true && context.maintenanceDegraded === true && @@ -231,18 +274,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) @@ -295,7 +351,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, @@ -311,7 +368,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]; @@ -368,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/startup-notifier.ts b/packages/opencode/src/startup-notifier.ts index d63f8d98..edb6064f 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 00000000..b2d3e4e0 --- /dev/null +++ b/packages/opencode/src/toast.ts @@ -0,0 +1,64 @@ +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, +): Promise { + if (hasShowToast(client)) { + return Promise.resolve(client.tui.showToast({ body: payload })); + } + + if (hasLegacyToast(client)) { + return Promise.resolve(client.tui.toast(payload)); + } + + return Promise.resolve(); +} diff --git a/packages/opencode/tests/agent-surface-policy.test.ts b/packages/opencode/tests/agent-surface-policy.test.ts index 64bdd4b7..d92d24a0 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/brief-intent.test.ts b/packages/opencode/tests/brief-intent.test.ts new file mode 100644 index 00000000..4b1a15e4 --- /dev/null +++ b/packages/opencode/tests/brief-intent.test.ts @@ -0,0 +1,311 @@ +/// +// 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[]; +}; + +type BriefIntentModule = { + deriveBriefIntent?: (params: BriefIntentParams) => BriefIntentResult; +}; + +function makeParams(overrides: Partial = {}): BriefIntentParams { + return { + riskClass: "behavior_candidate", + posture: "root_active", + maintenanceDegraded: false, + workspaceRoot: "/workspace", + branch: "feature/task-3", + editedFilePath: "/workspace/src/foo.ts", + ...overrides, + }; +} + +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-")); + }); + + afterEach(() => { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch {} + }); + + 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(Object.prototype.hasOwnProperty.call(result, "keepManualCue"), false); + assert.deepEqual(result.sourceFiles, ["/workspace/src/foo.ts"]); + assert.deepEqual(result.seedIds, []); + }); + + 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", + }); + + assert.equal(result.eligible, true); + assert.equal(result.reason, "Eligible for auto-briefing"); + }); + + 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("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("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("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("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("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")); + }); + + 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("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("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("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("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 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("uses the exact fingerprint serialization pattern", async () => { + const result = await derive({ + workspaceRoot: "/repo", + branch: "feature/brief", + editedFilePath: "/repo/src/feature.ts", + riskClass: "behavior_candidate", + }); + + assert.equal( + result.fingerprint, + "brief:/repo\0feature/brief\0/repo/src/feature.ts\0behavior_candidate", + ); + }); + + test("does not expose keepManualCue even when result is ineligible", async () => { + const result = await derive({ posture: "vendored_only" }); + + assert.equal(Object.prototype.hasOwnProperty.call(result, "keepManualCue"), false); + }); + + 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"], + }); + + assert.deepEqual(result.seedIds, ["REQ-001", "REQ-002", "REQ-003"]); + }); + + 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"), + }); + + assert.deepEqual(result.seedIds, ["REQ-001", "REQ-002", "REQ-003"]); + }); + + 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"), + }); + + assert.equal(result.eligible, true); + assert.deepEqual(result.seedIds, []); + assert.deepEqual(result.sourceFiles, [path.join(tmpDir, "src/foo.ts")]); + }); +}); 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 00000000..7b9c7aad --- /dev/null +++ b/packages/opencode/tests/briefing-auto-render.test.ts @@ -0,0 +1,542 @@ +/// +// 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"], + ...overrides, + } as BriefIntentResult; +} + +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); + }); +}); diff --git a/packages/opencode/tests/hook-contract.test.ts b/packages/opencode/tests/hook-contract.test.ts index 93035d31..ea76035e 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 () => { diff --git a/packages/opencode/tests/index.test.ts b/packages/opencode/tests/index.test.ts index 04d165f8..e27f8448 100644 --- a/packages/opencode/tests/index.test.ts +++ b/packages/opencode/tests/index.test.ts @@ -6,16 +6,22 @@ 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 * as toastModule from "../src/toast"; 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 +66,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 +3156,954 @@ 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("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); + 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); + 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("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"); + + 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(); + 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, + 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 () => { diff --git a/packages/opencode/tests/prompt.test.ts b/packages/opencode/tests/prompt.test.ts index 692b617b..e3903c0c 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,24 @@ const baseConfig: KibiConfig = { logLevel: "info", }; +function makeAutoBriefResult( + overrides: Partial = {}, +): BriefingRuntimeResult { + const state = overrides.state ?? "ready"; + const promptBlock = overrides.promptBlock ?? "- REQ-001: Auto summary"; + + return { + 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, + }; +} + describe("prompt", () => { test("buildPrompt returns guidance with sentinel", () => { const p = buildPrompt(); @@ -1038,6 +1062,247 @@ 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 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(); + + 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. @@ -1342,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([ {