From 7ea96b23d2e41b83445319de84a42e6828af94ca Mon Sep 17 00:00:00 2001 From: Ryan Helms Date: Tue, 25 Aug 2026 11:06:04 -0400 Subject: [PATCH] reconcile design patterns successor package --- .claude-plugin/marketplace.json | 2 - .github/workflows/publish.yml | 50 ++ .github/workflows/validate.yml | 6 + .gitignore | 1 + README.md | 23 +- bytedesk-package.yaml | 54 ++ packaging/source-tree-v1.json | 22 + .../.claude-plugin/plugin.json | 1 - plugins/design-patterns/.codex-mcp.json | 3 +- .../design-patterns/.codex-plugin/plugin.json | 29 +- .../design-patterns/.grok-plugin/plugin.json | 15 + plugins/design-patterns/.portable-mcp.json | 8 + plugins/design-patterns/AGENTS.md | 46 + plugins/design-patterns/CHANGELOG.md | 100 +++ plugins/design-patterns/LICENSE | 158 ++++ plugins/design-patterns/NOTICE | 5 + plugins/design-patterns/README.md | 313 +++++++ plugins/design-patterns/bin/patterns | 165 ++++ .../commands/patterns-history.md | 37 + .../docs/adr/0001-pattern-memory.md | 151 ++++ .../design-patterns/docs/catalog-authoring.md | 119 +++ .../docs/classic-object-pattern-coverage.md | 47 + plugins/design-patterns/evals/evals.json | 115 +++ .../evals/golden/context-pack.md | 19 + .../evals/golden/event-fanout-adr.md | 20 + .../evals/golden/graph-query.md | 12 + .../evals/golden/provider-recommendation.md | 15 + .../evals/golden/retry-smell-scan.md | 16 + .../evals/golden/strategy-shortlist.md | 17 + .../design-patterns/hooks/event-emitter.sh | 42 + plugins/design-patterns/hooks/hooks.json | 16 + plugins/design-patterns/hooks/record_edit.py | 42 + plugins/design-patterns/kimi.plugin.json | 9 + .../design-patterns/lib/pattern_mcp_server.py | 218 ++++- plugins/design-patterns/lib/pattern_memory.py | 809 ++++++++++++++++++ .../design-patterns/lib/workbench_views.py | 2 +- .../design-patterns/scripts/generate_site.py | 206 +++++ plugins/design-patterns/scripts/run_evals.py | 126 +++ .../scripts/validate_catalog.py | 648 ++++++++++++++ .../skills/architecture-decision/SKILL.md | 12 +- .../skills/architecture-issue-scan/SKILL.md | 7 +- .../skills/integration-flow-review/SKILL.md | 2 + .../skills/pattern-advisor/SKILL.md | 2 + .../skills/pattern-application/SKILL.md | 16 +- .../skills/pattern-finder/SKILL.md | 2 + plugins/design-patterns/tests/__init__.py | 1 + plugins/design-patterns/tests/smoke-memory.sh | 80 ++ plugins/design-patterns/tests/test_catalog.py | 349 ++++++++ .../design-patterns/tests/test_mcp_memory.py | 95 ++ .../tests/test_pattern_memory.py | 210 +++++ scripts/build_release.py | 72 ++ scripts/release_inventory.py | 66 ++ scripts/validate_catalog.py | 21 +- tests/test_release_contract.py | 149 ++++ 54 files changed, 4688 insertions(+), 83 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 bytedesk-package.yaml create mode 100644 packaging/source-tree-v1.json create mode 100644 plugins/design-patterns/.grok-plugin/plugin.json create mode 100644 plugins/design-patterns/.portable-mcp.json create mode 100644 plugins/design-patterns/AGENTS.md create mode 100644 plugins/design-patterns/CHANGELOG.md create mode 100644 plugins/design-patterns/LICENSE create mode 100644 plugins/design-patterns/NOTICE create mode 100644 plugins/design-patterns/README.md create mode 100644 plugins/design-patterns/commands/patterns-history.md create mode 100644 plugins/design-patterns/docs/adr/0001-pattern-memory.md create mode 100644 plugins/design-patterns/docs/catalog-authoring.md create mode 100644 plugins/design-patterns/docs/classic-object-pattern-coverage.md create mode 100644 plugins/design-patterns/evals/evals.json create mode 100644 plugins/design-patterns/evals/golden/context-pack.md create mode 100644 plugins/design-patterns/evals/golden/event-fanout-adr.md create mode 100644 plugins/design-patterns/evals/golden/graph-query.md create mode 100644 plugins/design-patterns/evals/golden/provider-recommendation.md create mode 100644 plugins/design-patterns/evals/golden/retry-smell-scan.md create mode 100644 plugins/design-patterns/evals/golden/strategy-shortlist.md create mode 100755 plugins/design-patterns/hooks/event-emitter.sh create mode 100755 plugins/design-patterns/hooks/hooks.json create mode 100755 plugins/design-patterns/hooks/record_edit.py create mode 100644 plugins/design-patterns/kimi.plugin.json create mode 100644 plugins/design-patterns/lib/pattern_memory.py create mode 100755 plugins/design-patterns/scripts/generate_site.py create mode 100755 plugins/design-patterns/scripts/run_evals.py create mode 100755 plugins/design-patterns/scripts/validate_catalog.py create mode 100644 plugins/design-patterns/tests/__init__.py create mode 100755 plugins/design-patterns/tests/smoke-memory.sh create mode 100644 plugins/design-patterns/tests/test_catalog.py create mode 100644 plugins/design-patterns/tests/test_mcp_memory.py create mode 100644 plugins/design-patterns/tests/test_pattern_memory.py create mode 100755 scripts/build_release.py create mode 100755 scripts/release_inventory.py create mode 100644 tests/test_release_contract.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index befa73b..ef66709 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -2,7 +2,6 @@ "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "bytedesk-design-patterns", "description": "ByteDeskAI marketplace for reusable design-pattern skills and reference catalogs.", - "version": "0.8.6", "owner": { "name": "ByteDeskAI" }, @@ -11,7 +10,6 @@ "name": "design-patterns", "source": "./plugins/design-patterns", "description": "Source-neutral pattern advisor for architecture, refactoring, language idioms, integration design, dynamic catalog exploration, architecture scanning, context packs, simulations, graph intelligence, MCP tooling, and implementation brief generation.", - "version": "0.8.6", "author": { "name": "ByteDeskAI" }, diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..b396b4b --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,50 @@ +{ + "name": "Validate Design Patterns release", + "on": { + "release": { + "types": ["published"] + }, + "workflow_dispatch": {} + }, + "permissions": { + "contents": "read", + "id-token": "write" + }, + "jobs": { + "publish": { + "runs-on": "ubuntu-latest", + "environment": "marketplace-production", + "steps": [ + { + "uses": "actions/checkout@v4" + }, + { + "uses": "actions/setup-python@v5", + "with": { + "python-version": "3.12" + } + }, + { + "name": "Validate source and release contract", + "run": "set -euo pipefail\npython3 scripts/validate_catalog.py\npython3 plugins/design-patterns/scripts/validate_catalog.py\npython3 -m unittest tests.test_release_contract\npython3 scripts/run_evals.py\n" + }, + { + "name": "Build deterministic provider artifacts", + "run": "set -euo pipefail\nmkdir -p dist\npython3 scripts/release_inventory.py > dist/release-inventory.json\npython3 scripts/release_inventory.py > \"$RUNNER_TEMP/release-inventory-second.json\"\ncmp dist/release-inventory.json \"$RUNNER_TEMP/release-inventory-second.json\"\npython3 scripts/build_release.py --output \"$RUNNER_TEMP/design-patterns-first\"\npython3 scripts/build_release.py --output \"$RUNNER_TEMP/design-patterns-second\"\ndiff -qr \"$RUNNER_TEMP/design-patterns-first\" \"$RUNNER_TEMP/design-patterns-second\"\npython3 scripts/build_release.py\n" + }, + { + "uses": "actions/upload-artifact@v4", + "with": { + "name": "design-patterns-release-candidate", + "path": "bytedesk-package.yaml\ndist/design-patterns\ndist/release-inventory.json\n", + "if-no-files-found": "error" + } + }, + { + "name": "Fail closed until bdm supports trusted-publisher OIDC", + "run": "echo \"::error title=Marketplace publication blocked::ByteDesk marketplace publication is blocked because bdm does not yet acquire GitHub Actions OIDC credentials.\"\necho \"Do not substitute a PAT, token, raw Actions JWT, or another secret.\"\nexit 1\n" + } + ] + } + } +} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d72894f..46f43af 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -18,6 +18,12 @@ jobs: run: python3 scripts/generate_site.py - name: Validate catalog and marketplace metadata run: python3 scripts/validate_catalog.py + - name: Validate self-contained release and deterministic inventory + run: | + python3 plugins/design-patterns/scripts/validate_catalog.py + python3 -m unittest tests.test_release_contract + test "$(python3 scripts/release_inventory.py)" = "$(python3 scripts/release_inventory.py)" + python3 scripts/build_release.py - name: Run unit tests run: python3 -m unittest discover - name: Run golden eval checks diff --git a/.gitignore b/.gitignore index fb812f1..9bbe999 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ .playwright-mcp/ __pycache__/ *.pyc +dist/ diff --git a/README.md b/README.md index 245c7d3..5129298 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Design Pattern References -Claude Code and Codex plugin marketplace for reusable design-pattern guidance. +Claude Code, Codex, Grok Build, and Kimi Code plugin marketplace for reusable design-pattern guidance. This repository is intended to be hosted at `ByteDeskAI/design-pattern-references` and added to Claude Code as a marketplace: @@ -15,6 +15,10 @@ It can also be added to Codex as a marketplace: codex plugin marketplace add ByteDeskAI/design-pattern-references ``` +Grok Build consumes the same Git marketplace through +`.grok-plugin/plugin.json`. Kimi Code consumes the server's version-2 catalog +projection and the bundled `kimi.plugin.json` manifest. + For local development from this checkout: ```bash @@ -41,11 +45,13 @@ codex plugin marketplace add . - A Python-backed dynamic catalog workbench exposed by the plugin CLI. - A stdio MCP server for tools that can call pattern recommendations, scans, context packs, ADRs, graph queries, simulations, and migrations. - Language profiles for C#, Java, TypeScript, Python, Go, Rust, and C++. -- A bundled `patterns` CLI that Claude Code and Codex can use after the plugin is installed. +- A bundled `patterns` CLI that Claude Code, Codex, Grok Build, and Kimi Code can use after the plugin is installed. ## Plugin Capability -After installation, Claude Code or Codex can use the `design-patterns` plugin when the user asks for pattern selection, architecture tradeoffs, refactoring guidance, or language-specific implementation approaches. +After installation, Claude Code, Codex, Grok Build, or Kimi Code can use the +`design-patterns` plugin when the user asks for pattern selection, architecture +tradeoffs, refactoring guidance, or language-specific implementation approaches. The plugin contributes: @@ -177,9 +183,9 @@ Omit `--language` and `--scope` unless you want to override inference. The plugi The repository includes three MCP configurations: -- [plugins/design-patterns/.mcp.json](/Users/kon1790/GitHub/design-pattern-reference/plugins/design-patterns/.mcp.json): packaged with the Claude plugin. It starts `design-patterns` through `${CLAUDE_PLUGIN_ROOT}/bin/patterns-mcp`, so global and project installs launch from the actual installed plugin directory. -- [plugins/design-patterns/.codex-mcp.json](/Users/kon1790/GitHub/design-pattern-reference/plugins/design-patterns/.codex-mcp.json): packaged with the Codex plugin. It uses the same install-root-aware launcher with Codex plugin-relative paths. -- [.mcp.json](/Users/kon1790/GitHub/design-pattern-reference/.mcp.json): project-scoped config for this repository checkout. Opening Claude in this project should show `design-patterns` as connected automatically. +- [`plugins/design-patterns/.mcp.json`](plugins/design-patterns/.mcp.json): packaged with Claude, Grok Build, and Kimi Code. Claude starts it through `${CLAUDE_PLUGIN_ROOT}/bin/patterns-mcp`; server adapters preserve provider-specific install-root handling. +- [`plugins/design-patterns/.codex-mcp.json`](plugins/design-patterns/.codex-mcp.json): packaged with Codex and uses plugin-relative paths. +- [`.mcp.json`](.mcp.json): project-scoped configuration for developing this repository. Claude verification: @@ -304,4 +310,7 @@ python3 scripts/run_evals.py ## Versioning -The marketplace and plugin versions move together. Bump both versions when publishing catalog or capability changes that users should receive through marketplace updates. +The immutable ByteDesk package, Codex, Grok Build, and Kimi Code manifests use +`0.9.3`. The internal Claude marketplace and plugin manifests remain versionless, +so Claude resolves their installed version from the immutable marketplace source +commit instead of serving a stale pinned cache. diff --git a/bytedesk-package.yaml b/bytedesk-package.yaml new file mode 100644 index 0000000..3ad0be2 --- /dev/null +++ b/bytedesk-package.yaml @@ -0,0 +1,54 @@ +{ + "$schema": "https://marketplace.bytedesk.ai/schemas/v1alpha1/bytedesk-package.schema.json", + "apiVersion": "marketplace.bytedesk.ai/v1alpha1", + "kind": "AgentPackageRelease", + "metadata": { + "namespace": "bytedesk", + "name": "design-patterns", + "version": "0.9.3", + "displayName": "Design Patterns" + }, + "spec": { + "visibility": "public", + "summary": "Source-neutral design-pattern guidance, architecture review, catalog tools, and durable project pattern memory.", + "license": "Apache-2.0", + "repository": "https://github.com/ByteDeskAI/design-pattern-references", + "homepage": "https://github.com/ByteDeskAI/design-pattern-references", + "keywords": [ + "architecture", + "design-patterns", + "integration-design", + "refactoring" + ], + "variants": [ + { + "id": "claude-code", + "provider": "claude-code", + "contract": "claude-plugin", + "contractVersion": "observed-2026-08-19", + "source": { "path": "dist/design-patterns" } + }, + { + "id": "openai-codex", + "provider": "openai-codex", + "contract": "codex-plugin", + "contractVersion": "observed-2026-08-19", + "source": { "path": "dist/design-patterns" } + }, + { + "id": "grok-build", + "provider": "grok-build", + "contract": "grok-plugin", + "contractVersion": "main-observed-2026-08-19", + "source": { "path": "dist/design-patterns" } + }, + { + "id": "kimi-code", + "provider": "kimi-code", + "contract": "kimi-plugin", + "contractVersion": "0.38.0", + "source": { "path": "dist/design-patterns" } + } + ] + } +} diff --git a/packaging/source-tree-v1.json b/packaging/source-tree-v1.json new file mode 100644 index 0000000..49b9e31 --- /dev/null +++ b/packaging/source-tree-v1.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "id": "source-tree-v1", + "revision": 1, + "source": "plugins/design-patterns", + "output": "dist/design-patterns", + "exclude": [ + ".in_use", + "**/__pycache__/**", + "**/*.pyc" + ], + "modePolicy": { + "regular": "0644", + "executable": "0755" + }, + "variants": [ + "claude-code", + "openai-codex", + "grok-build", + "kimi-code" + ] +} diff --git a/plugins/design-patterns/.claude-plugin/plugin.json b/plugins/design-patterns/.claude-plugin/plugin.json index ffed280..bf3c4a1 100644 --- a/plugins/design-patterns/.claude-plugin/plugin.json +++ b/plugins/design-patterns/.claude-plugin/plugin.json @@ -2,7 +2,6 @@ "$schema": "https://anthropic.com/claude-code/plugin.schema.json", "name": "design-patterns", "description": "Source-neutral advisor, Markdown reference catalog, MCP tooling, and dynamic workbench for software design patterns.", - "version": "0.8.6", "author": { "name": "ByteDeskAI" }, diff --git a/plugins/design-patterns/.codex-mcp.json b/plugins/design-patterns/.codex-mcp.json index dd739d6..364523f 100644 --- a/plugins/design-patterns/.codex-mcp.json +++ b/plugins/design-patterns/.codex-mcp.json @@ -2,8 +2,7 @@ "mcpServers": { "design-patterns": { "type": "stdio", - "command": "./bin/patterns-mcp", - "cwd": "." + "command": "./bin/patterns-mcp" } } } diff --git a/plugins/design-patterns/.codex-plugin/plugin.json b/plugins/design-patterns/.codex-plugin/plugin.json index 8062e70..c176f89 100644 --- a/plugins/design-patterns/.codex-plugin/plugin.json +++ b/plugins/design-patterns/.codex-plugin/plugin.json @@ -1,10 +1,9 @@ { "name": "design-patterns", - "version": "0.8.6", + "version": "0.9.3", "description": "Source-neutral advisor, Markdown reference catalog, MCP tooling, and dynamic workbench for software design patterns.", "author": { - "name": "ByteDeskAI", - "url": "https://github.com/ByteDeskAI" + "name": "ByteDeskAI" }, "homepage": "https://github.com/ByteDeskAI/design-pattern-references", "repository": "https://github.com/ByteDeskAI/design-pattern-references", @@ -20,8 +19,8 @@ "mcpServers": "./.codex-mcp.json", "interface": { "displayName": "Design Patterns", - "shortDescription": "Pattern advice, architecture review, and refactoring guidance.", - "longDescription": "A source-neutral Markdown catalog and skill bundle for finding, reviewing, deciding, scanning, scoring, documenting, exploring, and applying reusable software design patterns across object design, integration design, architecture smells, playbooks, recipes, framework packs, language-specific implementation idioms, snippets, context packs, migration plans, decision simulations, graph intelligence, MCP tooling, and a dynamic Python-backed catalog workbench.", + "shortDescription": "Source-neutral advisor, Markdown reference catalog, MCP tooling, and dynamic workbench for software design patterns.", + "longDescription": "Source-neutral advisor, Markdown reference catalog, MCP tooling, and dynamic workbench for software design patterns.", "developerName": "ByteDeskAI", "category": "Engineering", "capabilities": [ @@ -30,18 +29,14 @@ ], "websiteURL": "https://github.com/ByteDeskAI/design-pattern-references", "defaultPrompt": [ - "Find the right pattern for this design problem.", - "Review this architecture for pattern issues.", - "Draft an architecture decision using pattern tradeoffs.", - "Scan this repository for pattern-relevant architecture smells.", - "Serve the dynamic design pattern workbench for catalog exploration.", - "Generate an implementation brief from selected pattern candidates.", - "Plan this module toward a cleaner pattern.", - "Build a context pack from code evidence and pattern references.", - "Run the design-pattern MCP server for tool integrations.", - "Show copyable /patterns-* MCP request examples for this plugin.", - "Show /patterns-* command help for every design-pattern MCP tool.", - "Infer language and catalog scope from codebase and request context when omitted." + "Produce source-neutral architecture decision guidance using the design-pattern catalog, tradeoff analysis, and ADR-style output.", + "Find source-neutral design-pattern issues in code, architecture docs, PRs, diagrams, or design notes.", + "Review message-driven, event-driven, async workflow, broker, queue, stream, saga, or integration architecture.", + "Advise on selecting, comparing, applying, reviewing, or invoking reusable software design patterns.", + "Plan or implement a safe pattern-oriented refactor in an existing codebase.", + "Find and compare reusable design patterns from a problem statement.", + "Generate an ADR-style seed backed by the pattern catalog", + "Build a model-ready pattern context pack for code and a design question" ] } } diff --git a/plugins/design-patterns/.grok-plugin/plugin.json b/plugins/design-patterns/.grok-plugin/plugin.json new file mode 100644 index 0000000..787efe3 --- /dev/null +++ b/plugins/design-patterns/.grok-plugin/plugin.json @@ -0,0 +1,15 @@ +{ + "name": "design-patterns", + "version": "0.9.3", + "description": "Source-neutral advisor, Markdown reference catalog, MCP tooling, and dynamic workbench for software design patterns.", + "author": { + "name": "ByteDeskAI" + }, + "homepage": "https://github.com/ByteDeskAI/design-pattern-references", + "repository": "https://github.com/ByteDeskAI/design-pattern-references", + "license": "Apache-2.0", + "skills": "./skills", + "agents": "./agents", + "commands": "./commands", + "mcpServers": "./.portable-mcp.json" +} diff --git a/plugins/design-patterns/.portable-mcp.json b/plugins/design-patterns/.portable-mcp.json new file mode 100644 index 0000000..364523f --- /dev/null +++ b/plugins/design-patterns/.portable-mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "design-patterns": { + "type": "stdio", + "command": "./bin/patterns-mcp" + } + } +} diff --git a/plugins/design-patterns/AGENTS.md b/plugins/design-patterns/AGENTS.md new file mode 100644 index 0000000..b502802 --- /dev/null +++ b/plugins/design-patterns/AGENTS.md @@ -0,0 +1,46 @@ +# Design Patterns + +Source-neutral advisor, Markdown reference catalog, MCP tooling, and dynamic workbench for software design patterns. + +This plugin works across Claude Code, Codex, Grok Build, and Kimi Code. Claude +Code loads `.claude-plugin/plugin.json`, Codex loads +`.codex-plugin/plugin.json`, Grok Build loads `.grok-plugin/plugin.json`, and +Kimi Code loads `kimi.plugin.json`. + +## MCP server + +Register the `design-patterns` stdio MCP server. Claude reads `.mcp.json`, +Codex reads `.codex-mcp.json`, and the Grok/Kimi manifests reference +`.portable-mcp.json`: + +```json +{ + "mcpServers": { + "design-patterns": { + "type": "stdio", + "command": "/design-patterns/bin/patterns-mcp" + } + } +} +``` + +## Skills & commands + +- **architecture-decision** (skill) — Produce source-neutral architecture decision guidance using the design-pattern catalog, tradeoff analysis, and ADR-style output. +- **architecture-issue-scan** (skill) — Find source-neutral design-pattern issues in code, architecture docs, PRs, diagrams, or design notes. +- **integration-flow-review** (skill) — Review message-driven, event-driven, async workflow, broker, queue, stream, saga, or integration architecture. +- **pattern-advisor** (skill) — Advise on selecting, comparing, applying, reviewing, or invoking reusable software design patterns. +- **pattern-application** (skill) — Plan or implement a safe pattern-oriented refactor in an existing codebase. +- **pattern-finder** (skill) — Find and compare reusable design patterns from a problem statement. +- **patterns-adr** (command) — Generate an ADR-style seed backed by the pattern catalog +- **patterns-context** (command) — Build a model-ready pattern context pack for code and a design question +- **patterns-examples** (command) — Show copyable Design Patterns slash commands and MCP request examples +- **patterns-graph** (command) — Query the typed pattern catalog graph and relationships +- **patterns-help** (command) — Show help for all Design Patterns slash commands or one command +- **patterns-history** (command) — Recall this project's pattern memory — prior scans, decisions, and applied refactors +- **patterns-migrate** (command) — Plan a migration from a current smell or shape to a target pattern +- **patterns-recommend** (command) — Recommend design patterns for an architecture force or problem +- **patterns-scan** (command) — Scan a file or directory for pattern-relevant architecture smells +- **patterns-simulate** (command) — Score pattern options against architecture decision criteria +- **patterns-snippets** (command) — Fetch language-specific implementation snippets for pattern slugs +- **pattern-architect** (agent) — Reviews architecture and code through source-neutral design-pattern domains. diff --git a/plugins/design-patterns/CHANGELOG.md b/plugins/design-patterns/CHANGELOG.md new file mode 100644 index 0000000..2734918 --- /dev/null +++ b/plugins/design-patterns/CHANGELOG.md @@ -0,0 +1,100 @@ +# Changelog + +## [0.9.3] — 2026-08-25 + +### Changed + +- Restored the standalone repository as the release authority for the complete + pattern-memory, hook, validation, evaluation, and documentation surface. +- Added deterministic Claude, Codex, Grok Build, and Kimi Code provider + manifests under one `@bytedesk/design-patterns@0.9.3` release contract. +- Kept the internal Claude plugin and marketplace entries versionless so their + installed version follows the immutable marketplace source revision. +- Vendored the Apache-2.0 license and NOTICE into the self-contained plugin + tree and removed machine-specific documentation links. + +All notable changes to the `design-patterns` plugin are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this plugin adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.9.2] — 2026-06-25 + +### Added + +- `.codex-plugin/plugin.json` (Codex marketplace manifest, mirrors the Claude manifest plus an `interface` block) and `AGENTS.md` so the plugin loads under Codex and grok-cli in addition to Claude Code, plus `.codex-mcp.json` for the MCP server. + +## [0.9.1] — 2026-06-03 + +### Fixed + +- PostToolUse hook matcher and recorder now recognize the `search_replace` tool name (in addition to `Edit`/`Write`/`MultiEdit`). This is the tool used by Grok Code / the bytedesk-terminal agent and by `search_replace` edits in this environment. Ensures that code changes performed by the assistant are recorded as edit breadcrumbs to the project's pattern memory journal (`.claude/plugins/design-patterns/journal.jsonl` via `record_edit`). Updated `hooks/hooks.json` and `hooks/record_edit.py`. Tracked alongside OAuth intercept work in the terminal. + +## [0.9.0] — 2026-05-14 + +Minor release: cross-session **pattern memory**. The plugin used to be entirely +stateless — every call reloaded the catalog and returned JSON, ADRs were printed +but never saved, scans were ephemeral. It now remembers what it found, +recommended, decided, and what refactors were applied, and reads that memory +back so its answers build on prior work instead of repeating it. Tracked as the +BDM-52 epic. + +### Added + +- **`lib/pattern_memory.py` (BDM-54):** the persistence + recall layer — an + append-only JSONL journal at `/.claude/plugins/design-patterns/` + (with a per-user global fallback when the working directory is not a project + repo). Writers (`record_scan` / `record_recommendation` / `record_decision` / + `record_decision_status` / `record_applied` / `record_edit`) never raise; + readers fold the journal into current decisions, a scan diff, an + applied-pattern index, and a one-stop recall summary. +- **`patterns_record` + `patterns_recall` MCP tools (BDM-55):** record durable + outcomes (applied refactors, ADR status changes) and recall what the project + already knows. Dispatched dynamically — no `handle_request` change. +- **`/patterns-history` slash command (BDM-57):** the user-facing recall + surface, backed by `patterns_recall`; registered in `SLASH_COMMAND_HELP`. +- **`patterns memory` CLI subcommands (BDM-56):** `where` / `recall` / `record` + / `render` on `bin/patterns`, respecting the existing `--json` convention. +- **PostToolUse capture hook (BDM-56):** `hooks/hooks.json` + + `hooks/event-emitter.sh` + `hooks/record_edit.py` drop a coarse "edit" + breadcrumb into the journal on `Edit` / `Write` / `MultiEdit`. Never blocks a + tool call. +- **Markdown ADR + index renderers (BDM-58):** every recorded decision or + applied refactor regenerates `decisions/NNNN-slug.md` and `index.md` from the + journal — deterministic, so a re-render is a git no-op. +- **`docs/adr/0001-pattern-memory.md`:** the design record for this feature. +- **`tests/test_pattern_memory.py`, `tests/test_mcp_memory.py`, + `tests/smoke-memory.sh`:** unit, MCP-dispatch, and CLI/stdio coverage for the + memory layer. + +### Changed + +- **Memory-aware skills (BDM-57):** all six skills now consult + `patterns memory recall` before advising; `pattern-application` records the + applied refactor afterward, `architecture-decision` records ADR status + transitions, `architecture-issue-scan` leads with the scan `memoryDiff`. +- **Auto-capture in the existing surfaces (BDM-55 / BDM-57):** `patterns_scan` + and CLI `scan` attach a `memoryDiff` (new / resolved smells vs the last scan of + that path) and record the scan; `patterns_adr` and CLI `adr` record the ADR + and surface its `adrNumber`; `patterns_recommend` surfaces `priorDecisions` + plus a `memoryHint` when the project already has decisions for a related + force. +- **`tests/test_catalog.py`:** isolates pattern-memory side effects to a + throwaway dir so regression runs never touch the real journal. + +### Build + +- Version bumped `0.8.6` → `0.9.0` across `.claude-plugin/plugin.json`, + `.codex-plugin/plugin.json`, `lib/pattern_mcp_server.py` (`SERVER_INFO`), and + `lib/workbench_views.py`; the marketplace manifest's `design-patterns` entry + and top-level version move in lockstep. + +## [0.8.6] — 2026-05-14 + +Baseline: the plugin as onboarded into `bytedesk-marketplace` (BDM-53) from +`ByteDeskAI/design-pattern-references` — prior history lives in that repository. +At this version the plugin is entirely stateless: a source-neutral Markdown +catalog (120+ patterns plus playbooks, recipes, smells, frameworks, languages, +scorecards, snippets, taxonomy), an MCP server, 10 slash commands, 6 skills, the +`pattern-architect` agent, and a dynamic Python-backed workbench. diff --git a/plugins/design-patterns/LICENSE b/plugins/design-patterns/LICENSE new file mode 100644 index 0000000..ddfd265 --- /dev/null +++ b/plugins/design-patterns/LICENSE @@ -0,0 +1,158 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, "control" means (i) the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, +made available under the License, as indicated by a copyright notice that is +included in or attached to the work. + +"Derivative Works" shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +"submitted" means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this +License, each Contributor hereby grants to You a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable copyright license to +reproduce, prepare Derivative Works of, publicly display, publicly perform, +sublicense, and distribute the Work and such Derivative Works in Source or +Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, +each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable patent license to make, have made, use, +offer to sell, sell, import, and otherwise transfer the Work, where such license +applies only to those patent claims licensable by such Contributor that are +necessarily infringed by their Contribution(s) alone or by combination of their +Contribution(s) with the Work to which such Contribution(s) was submitted. If +You institute patent litigation against any entity alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or +Derivative Works thereof in any medium, with or without modifications, and in +Source or Object form, provided that You meet the following conditions: + +(a) You must give any other recipients of the Work or Derivative Works a copy of +this License; and + +(b) You must cause any modified files to carry prominent notices stating that +You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works that You +distribute, all copyright, patent, trademark, and attribution notices from the +Source form of the Work, excluding those notices that do not pertain to any part +of the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its distribution, then +any Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any +Contribution intentionally submitted for inclusion in the Work by You to the +Licensor shall be under the terms and conditions of this License, without any +additional terms or conditions. Notwithstanding the above, nothing herein shall +supersede or modify the terms of any separate license agreement you may have +executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, +trademarks, service marks, or product names of the Licensor, except as required +for reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in +writing, Licensor provides the Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, +MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely +responsible for determining the appropriateness of using or redistributing the +Work and assume any risks associated with Your exercise of permissions under +this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in +tort (including negligence), contract, or otherwise, unless required by +applicable law (such as deliberate and grossly negligent acts) or agreed to in +writing, shall any Contributor be liable to You for damages, including any +direct, indirect, special, incidental, or consequential damages of any character +arising as a result of this License or out of the use or inability to use the +Work, including but not limited to damages for loss of goodwill, work stoppage, +computer failure or malfunction, or any and all other commercial damages or +losses, even if such Contributor has been advised of the possibility of such +damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or +Derivative Works thereof, You may choose to offer, and charge a fee for, +acceptance of support, warranty, indemnity, or other liability obligations +and/or rights consistent with this License. However, in accepting such +obligations, You may act only on Your own behalf and on Your sole +responsibility, not on behalf of any other Contributor, and only if You agree to +indemnify, defend, and hold each Contributor harmless for any liability incurred +by, or claims asserted against, such Contributor by reason of your accepting any +such warranty or additional liability. + +END OF TERMS AND CONDITIONS + diff --git a/plugins/design-patterns/NOTICE b/plugins/design-patterns/NOTICE new file mode 100644 index 0000000..61d9863 --- /dev/null +++ b/plugins/design-patterns/NOTICE @@ -0,0 +1,5 @@ +Design Pattern References + +This repository contains original summaries and Claude Code plugin guidance for reusable software design patterns. + +Pattern entries are organized as a source-neutral Markdown catalog by domain, category, group, and language applicability. diff --git a/plugins/design-patterns/README.md b/plugins/design-patterns/README.md new file mode 100644 index 0000000..3fda8c2 --- /dev/null +++ b/plugins/design-patterns/README.md @@ -0,0 +1,313 @@ +# Design Pattern References + +Claude Code, Codex, Grok Build, and Kimi Code plugin for reusable design-pattern guidance. + +This repository is intended to be hosted at `ByteDeskAI/design-pattern-references` and added to Claude Code as a marketplace: + +```bash +claude plugin marketplace add ByteDeskAI/design-pattern-references +claude plugin install design-patterns@bytedesk-design-patterns +``` + +It can also be added to Codex as a marketplace: + +```bash +codex plugin marketplace add ByteDeskAI/design-pattern-references +``` + +Grok Build consumes `.grok-plugin/plugin.json`. Kimi Code consumes the +server-projected version-2 catalog and this bundle's `kimi.plugin.json`. + +For local development from this checkout: + +```bash +claude plugin validate . +claude plugin marketplace add . +claude plugin install design-patterns@bytedesk-design-patterns +codex plugin marketplace add . +``` + +## What Is Included + +- A Claude Code marketplace manifest at `.claude-plugin/marketplace.json`. +- A Codex marketplace manifest at `.agents/plugins/marketplace.json`. +- One installable plugin at `plugins/design-patterns`. +- A source-neutral Markdown catalog of reusable design patterns. +- Pattern domains for object design, integration design, messaging, transformation, endpoints, operations, construction, structure, and collaboration. +- Architecture playbooks for recurring pattern combinations. +- Architecture smells for detecting design risks before recommending patterns. +- Framework implementation packs for concrete stack guidance. +- Pattern application recipes for safe refactor steps. +- Decision scorecards for comparing architectural options. +- Architecture force taxonomy, synonym expansion, and language-specific implementation snippets. +- A generated searchable static catalog site inside the plugin bundle. +- A Python-backed dynamic catalog workbench exposed by the plugin CLI. +- A stdio MCP server for tools that can call pattern recommendations, scans, context packs, ADRs, graph queries, simulations, and migrations. +- Language profiles for C#, Java, TypeScript, Python, Go, Rust, and C++. +- A bundled `patterns` CLI that Claude Code, Codex, Grok Build, and Kimi Code can use after the plugin is installed. + +## Plugin Capability + +After installation, Claude Code, Codex, Grok Build, or Kimi Code can use the +`design-patterns` plugin when the user asks for pattern selection, architecture +tradeoffs, refactoring guidance, or language-specific implementation approaches. + +The plugin contributes: + +- `skills/pattern-advisor/SKILL.md`: general model-invoked pattern guidance. +- `skills/pattern-finder/SKILL.md`: discover and compare candidate patterns from a problem statement. +- `skills/architecture-decision/SKILL.md`: produce ADR-style pattern decisions, tradeoffs, consequences, and verification plans. +- `skills/architecture-issue-scan/SKILL.md`: find design and integration issues in code or architecture notes. +- `skills/pattern-application/SKILL.md`: plan and apply a pattern-oriented refactor safely. +- `skills/integration-flow-review/SKILL.md`: review message-driven and integration flows. +- `agents/pattern-architect.md`: deeper architecture and design-review agent. +- `bin/patterns`: local catalog lookup, architecture scan, ADR, graph, context-pack, decision-simulation, migration, MCP, and dynamic workbench helper. +- `.mcp.json`: Claude plugin MCP config that resolves from the installed plugin root. +- `.codex-mcp.json`: Codex plugin MCP config that resolves from the installed plugin root. +- root `.mcp.json`: project-scoped Claude MCP config so this repository shows `design-patterns` connected when opened as a Claude project. +- `data/patterns/*.md`: canonical Markdown pattern entries. +- `data/playbooks/*.md`: source-neutral pattern-composition playbooks. +- `data/smells/*.md`: source-neutral architecture smells and pattern responses. +- `data/frameworks/*.md`: stack-specific implementation packs. +- `data/recipes/*.md`: pattern application recipes. +- `data/scorecards/*.md`: architecture decision scorecards. +- `data/snippets/**/*.md`: language-specific implementation snippets. +- `data/taxonomy/*.md`: force and synonym maps used by recommendation intelligence. +- `data/languages/*.md`: canonical Markdown language profiles. +- `site/index.html`: generated searchable catalog site packaged inside the plugin. +- `docs/classic-object-pattern-coverage.md`: source-neutral coverage audit for the classic 23 object-design patterns and Python language support. +- `skills/*/references/{usages,examples,implementation,catalog}.md`: detailed skill documentation loaded on demand. +- `commands/patterns-*.md`: Claude slash-command wrappers for copyable MCP-backed requests such as `/patterns-recommend`, `/patterns-scan`, and `/patterns-context`; each command supports `help`. + +Each skill declares fully qualified skill frontmatter: `name`, `description`, `when_to_use`, `argument-hint`, invocation controls, conservative `allowed-tools`, and `model: inherit`. Slash commands and agents also expose `argument-hint` frontmatter where supported, and every MCP tool input property includes a description so clients can show argument helpers. MCP tools infer safe optional arguments from request text, project paths, and codebase markers, then return structured missing-argument detail when required intent cannot be inferred. + +## Catalog Model + +The catalog is intentionally source-neutral. Patterns are organized by domain, category, group, and language applicability rather than by origin. New patterns can be added from any useful tradition, codebase, architecture review, or language ecosystem by adding a Markdown file under `plugins/design-patterns/data/patterns`. + +Each pattern file uses frontmatter for machine filtering and Markdown sections for Claude-readable guidance. Pattern entries include decision metadata such as quality attributes, tradeoffs, failure modes, testing focus, observability focus, typed relationships, and implementation notes: + +```text +--- +slug: strategy +name: Strategy +domain: behavior-and-collaboration +category: Behavior and Collaboration +groups: + - object-design +languages: + - csharp + - typescript +related: + - state +relationships: + - alternative:state +references: + - skills/pattern-advisor/references/implementation.md +--- + +# Strategy + +## Intent +... +``` + +Use the CLI to inspect the catalog: + +```bash +plugins/design-patterns/bin/patterns domains +plugins/design-patterns/bin/patterns list object-design --language typescript +plugins/design-patterns/bin/patterns search router --scope integration-design --language typescript +plugins/design-patterns/bin/patterns recommend "duplicate delivery repeats side effects" --scope integration-design --language csharp +plugins/design-patterns/bin/patterns recommend "provider selection leaks into domain code" --risk operability --explain +plugins/design-patterns/bin/patterns compare strategy state template-method +plugins/design-patterns/bin/patterns adr "duplicate delivery repeats side effects" +plugins/design-patterns/bin/patterns graph --format mermaid +plugins/design-patterns/bin/patterns graph --query "what mitigates naive exactly once" --json +plugins/design-patterns/bin/patterns explain strategy +plugins/design-patterns/bin/patterns why "provider selection leaks into domain code" +plugins/design-patterns/bin/patterns scan ./src --pack integration --min-confidence 0.7 --json +plugins/design-patterns/bin/patterns context ./src --query "messages can redeliver" --language python +plugins/design-patterns/bin/patterns simulate "duplicate delivery repeats side effects" --language python +plugins/design-patterns/bin/patterns migrate provider-switch-sprawl --to bridge +plugins/design-patterns/bin/patterns snippets idempotent-receiver --language python +plugins/design-patterns/bin/patterns mcp +plugins/design-patterns/bin/patterns serve --port 8766 +plugins/design-patterns/bin/patterns playbooks event-fanout +plugins/design-patterns/bin/patterns smells naive-exactly-once +plugins/design-patterns/bin/patterns frameworks dotnet-masstransit +plugins/design-patterns/bin/patterns recipes strategy-refactor +plugins/design-patterns/bin/patterns scorecards standard-architecture-decision +plugins/design-patterns/bin/patterns show strategy --language csharp +plugins/design-patterns/bin/patterns languages go +``` + +## Architecture Guidance Model + +The plugin now supports three layers of guidance: + +- Patterns: individual reusable design responses. +- Playbooks: source-neutral combinations of patterns for recurring architecture situations. +- Smells: detectable design risks with pattern or no-pattern responses. +- Framework packs: stack-specific implementation, testing, and operations guidance. +- Recipes: step-by-step refactor and hardening paths. +- Scorecards: consistent architecture decision criteria. +- Taxonomy: architecture-force and synonym maps that improve matching. +- Snippets: small language-specific implementation references tied to catalog patterns. + +Skills should use the catalog progressively: detect smells, select patterns or playbooks, compare alternatives, then produce decision-ready output with consequences, tests, observability, and rollback signals. + +The CLI can also generate ADR-style decision drafts, export and query the typed catalog graph, explain catalog entries, explain why recommendations matched, scan a repository for pattern-relevant architecture smells, build context packs, score options through a decision simulation, produce migration plans, list snippets, and run a stdio MCP server. + +## Slash Command Examples + +For user-facing MCP requests, prefer the plugin slash commands. Ask for `/patterns-examples` to get the full copyable list. + +```text +/patterns-help +/patterns-scan help +/patterns-recommend "add a new SCM provider without changing rule execution code" --limit 5 +/patterns-scan backend/app/workflow_engine --min-confidence 0.45 +/patterns-context backend/app/providers/ai --query "adding a new AI provider safely" +/patterns-simulate "Strategy vs Chain of Responsibility for AI provider failover" --risk operability +/patterns-migrate "hardcoded if/elif provider selection" --to strategy +/patterns-snippets strategy,idempotent-receiver +/patterns-adr "durable event storage for SSE replay: Redis vs PostgreSQL" +/patterns-graph "what patterns mitigate naive exactly once" +``` + +Omit `--language` and `--scope` unless you want to override inference. The plugin infers both from prompt terms, command paths, stack markers, and nearby project files. The MCP server also exposes `patterns_examples` and `patterns_help`; they return these slash commands, help forms, corresponding MCP tool names, inferred-context behavior, and JSON arguments for agents that inspect schemas before answering. + +## MCP Auto-Start + +The repository includes three MCP configurations: + +- [`.mcp.json`](.mcp.json): shared Claude, Grok Build, and Kimi Code MCP declaration. Provider adapters resolve the installed plugin root. +- [`.codex-mcp.json`](.codex-mcp.json): Codex MCP declaration using plugin-relative paths. + +Claude verification: + +```bash +claude mcp get design-patterns +claude mcp list +``` + +Codex compatibility is declared through `plugins/design-patterns/.codex-plugin/plugin.json` with `mcpServers: "./.codex-mcp.json"`, while the repository root `.mcp.json` remains project-scoped for local Claude development. + +## Dynamic Catalog Workbench + +Run the Python-backed catalog app locally: + +```bash +plugins/design-patterns/bin/patterns serve --port 8766 +``` + +Open `http://127.0.0.1:8766/`. + +The workbench is backed by live Markdown catalog data and includes: + +- full-text search across patterns, playbooks, smells, frameworks, recipes, scorecards, and languages; +- kind, domain, group, language, and quality filters; +- entry detail inspection with forces, tradeoffs, tests, observability, and related tags; +- compare tray for selected entries; +- scenario radar recommendations with matched terms and decision paths; +- paste-in architecture scan for smell detection and pattern responses; +- ADR draft generation from a decision prompt; +- implementation brief generation from selected entries or compare sets; +- relationship graph view plus API support for graph questions; +- coverage matrix for languages, quality attributes, domains, risk, and complexity; +- API endpoints for context packs, decision simulations, migration plans, and snippets; +- Python and classic object-pattern coverage checks. + +## Validation + +Run the local validation script: + +```bash +python3 scripts/validate_catalog.py +``` + +Regenerate the static catalog site after catalog changes: + +```bash +python3 scripts/generate_site.py +``` + +If Claude Code is installed, also run: + +```bash +claude plugin validate . +``` + +Codex marketplace metadata is validated by `scripts/validate_catalog.py`. + +Unit tests cover the catalog loader and CLI behavior: + +```bash +python3 -m unittest +``` + +Golden eval checks verify expected architecture-output sections and terms: + +```bash +python3 scripts/run_evals.py +``` + +## Repository Layout + +```text +. +├── .claude-plugin/ +│ └── marketplace.json +├── .agents/ +│ └── plugins/marketplace.json +├── plugins/ +│ └── design-patterns/ +│ ├── .codex-mcp.json +│ ├── .mcp.json +│ ├── .claude-plugin/plugin.json +│ ├── .codex-plugin/plugin.json +│ ├── agents/pattern-architect.md +│ ├── bin/patterns +│ ├── commands/patterns-*.md +│ ├── data/ +│ │ ├── languages/*.md +│ │ ├── patterns/*.md +│ │ ├── playbooks/*.md +│ │ ├── smells/*.md +│ │ ├── frameworks/*.md +│ │ ├── recipes/*.md +│ │ ├── scorecards/*.md +│ │ ├── snippets/**/*.md +│ │ └── taxonomy/*.md +│ ├── lib/pattern_catalog.py +│ ├── lib/pattern_context.py +│ ├── lib/pattern_graph.py +│ ├── lib/pattern_intelligence.py +│ ├── lib/pattern_mcp_server.py +│ ├── lib/pattern_scanner.py +│ ├── lib/pattern_workbench.py +│ ├── lib/workbench_api.py +│ ├── lib/workbench_assets.py +│ ├── lib/workbench_views.py +│ ├── site/index.html +│ └── skills/ +│ ├── architecture-decision/SKILL.md +│ ├── architecture-issue-scan/SKILL.md +│ ├── integration-flow-review/SKILL.md +│ ├── pattern-advisor/SKILL.md +│ ├── pattern-application/SKILL.md +│ └── pattern-finder/SKILL.md +├── docs/catalog-authoring.md +├── docs/classic-object-pattern-coverage.md +└── scripts/ + ├── generate_site.py + ├── run_evals.py + └── validate_catalog.py +``` + +## Versioning + +The immutable ByteDesk package, Codex, Grok Build, and Kimi Code manifests use +`0.9.3`. The internal Claude manifest remains versionless and resolves its +installed version from the immutable marketplace source commit. diff --git a/plugins/design-patterns/bin/patterns b/plugins/design-patterns/bin/patterns index ebd7955..9fe0dea 100755 --- a/plugins/design-patterns/bin/patterns +++ b/plugins/design-patterns/bin/patterns @@ -37,6 +37,15 @@ from pattern_intelligence import ( # noqa: E402 recommend_entries, ) from pattern_mcp_server import serve_stdio # noqa: E402 +from pattern_memory import ( # noqa: E402 + journal_location, + recall_summary, + record_decision, + record_from_tool, + record_scan, + render as render_memory, + scan_diff, +) from pattern_scanner import scan_path # noqa: E402 @@ -78,6 +87,14 @@ def emit(value: object, as_json: bool, language: str | None = None) -> None: if "findings" in value: print(f"Scanned: {value.get('path')}") print(f"Findings: {value.get('count', 0)}") + diff = value.get("memoryDiff") + if isinstance(diff, dict) and diff.get("comparedToPrevious"): + print( + f"Since last scan ({diff.get('since')}): " + f"{len(diff.get('newSmells', []))} new, " + f"{len(diff.get('resolvedSmells', []))} resolved, " + f"{diff.get('unchanged', 0)} unchanged" + ) for finding in value.get("findings", [])[:80]: print( f"- {finding.get('severity', 'P?')} {finding['name']} at {finding['file']}:{finding['line']} " @@ -304,6 +321,14 @@ def recommend(query: str, scope: str, language: str | None, limit: int, risk: st def emit_adr(query: str, scope: str, language: str | None, status: str, as_json: bool) -> None: payload = adr_payload(query, status=status, language=language, scope=scope) + # Feed pattern memory, mirroring the patterns_adr MCP tool: record the ADR seed + # and surface the number a later session can transition or build on. + try: + recorded = record_decision(payload, status=status, scope=scope, language=language) + if isinstance(recorded, dict) and isinstance(recorded.get("adrNumber"), int): + payload["adrNumber"] = recorded["adrNumber"] + except Exception: + pass if as_json: emit(payload, True) return @@ -328,6 +353,12 @@ def emit_adr(query: str, scope: str, language: str | None, status: str, as_json: print("## Verification") for item in payload["verification"]: print(f"- {item}") + if payload.get("adrNumber"): + print() + print( + f"_Recorded to pattern memory as ADR-{payload['adrNumber']}. On acceptance: " + f"patterns memory record --kind decision --adr {payload['adrNumber']} --status accepted_" + ) def emit_graph(format_name: str, query: str | None, as_json: bool) -> None: @@ -398,6 +429,15 @@ def scan(path: str, pack: str, include_docs: bool, include_generated: bool, min_ except FileNotFoundError as exc: print(str(exc), file=sys.stderr) return 1 + # Feed pattern memory, mirroring the patterns_scan MCP tool: diff against the + # last stored scan of this path, then record this one. + try: + diff = scan_diff(path, payload) + record_scan(payload, path=path, pack=pack, min_confidence=min_confidence) + if isinstance(payload, dict): + payload["memoryDiff"] = diff + except Exception: + pass emit(payload, as_json) return 0 @@ -422,6 +462,102 @@ def serve_workbench(host: str, port: int) -> int: return run_server(host, port) +def _print_memory(action: str, payload: Any) -> None: + """Compact human-readable rendering of a `patterns memory` result.""" + if action == "where": + print(f"mode: {payload.get('mode')}") + print(f"root: {payload.get('root')}") + journal = payload.get("journal") + print(f"journal: {journal} ({'exists' if payload.get('exists') else 'not created yet'})") + if payload.get("projectRoot"): + print(f"project: {payload['projectRoot']}") + return + if action == "record": + if payload.get("recorded") is False: + print(f"not recorded: {payload.get('error')}", file=sys.stderr) + else: + print(f"recorded {payload.get('kind')} event {payload.get('id', '')}".rstrip()) + return + if action == "render": + print(f"rendered decisions + index at {payload.get('root')}") + return + # recall + print(f"mode: {payload.get('mode')} root: {payload.get('root')}") + print( + f"events: {payload.get('eventCount', 0)} " + f"decisions: {payload.get('decisionCount', 0)} " + f"applied: {payload.get('appliedCount', 0)}" + ) + for decision in payload.get("decisions") or []: + print(f" ADR-{decision.get('adrNumber')} [{decision.get('status')}] {decision.get('title') or ''}".rstrip()) + index = payload.get("patternIndex") or {} + if index: + print("patterns applied:") + for slug, info in sorted(index.items()): + print(f" {slug} -> {', '.join(info.get('modules', []))}".rstrip()) + if payload.get("matchedDecisions"): + print("matched decisions:") + for decision in payload["matchedDecisions"]: + print(f" ADR-{decision.get('adrNumber')} [{decision.get('status')}] {decision.get('title') or ''}".rstrip()) + recent = payload.get("recentEvents") or [] + if recent: + print("recent:") + for event in recent[:10]: + kind = event.get("kind") + if kind == "scan": + extra = f" {event.get('path')} ({event.get('findingCount', 0)} findings)" + elif kind == "decision": + extra = f" ADR-{event.get('adrNumber')} [{event.get('status')}]" + elif kind == "applied": + extra = f" {event.get('pattern')} -> {event.get('targetModule')}" + elif kind == "recommendation": + extra = f" {event.get('primarySlug') or ''}" + elif kind == "edit": + extra = f" {event.get('file')}" + else: + extra = "" + print(f" {event.get('ts')} {kind}{extra}".rstrip()) + + +def memory_command(args: argparse.Namespace, as_json: bool) -> int: + action = args.memory_action + if action == "where": + payload: Any = journal_location() + elif action == "recall": + payload = recall_summary( + query=getattr(args, "query", None) or None, + path=getattr(args, "path", None) or None, + limit=args.limit, + ) + elif action == "record": + payload = record_from_tool( + args.kind, + { + "kind": args.kind, + "pattern": args.pattern, + "target": args.target, + "adr": args.adr, + "status": args.status, + "outcome": args.outcome, + "summary": args.summary, + "sourceShape": args.source_shape, + "verified": args.verified, + }, + ) + elif action == "render": + render_memory() + payload = {"rendered": True, **journal_location()} + else: + return 1 + if as_json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + _print_memory(action, payload) + if action == "record" and isinstance(payload, dict) and payload.get("recorded") is False: + return 1 + return 0 + + def add_json(parser: argparse.ArgumentParser) -> None: parser.add_argument("--json", action="store_true", dest="command_json", help="Emit JSON") @@ -546,6 +682,33 @@ def main() -> int: subparsers.add_parser("mcp", help="Run the stdio MCP server for marketplace/tool integrations") + memory_parser = subparsers.add_parser("memory", help="Inspect and record cross-session pattern memory") + memory_sub = memory_parser.add_subparsers(dest="memory_action", required=True) + + memory_where = memory_sub.add_parser("where", help="Show where the pattern-memory journal lives (project vs global)") + add_json(memory_where) + + memory_recall = memory_sub.add_parser("recall", help="Recall prior scans, decisions, and applied refactors") + memory_recall.add_argument("--query", "-q", help="Surface prior ADR decisions matching this force") + memory_recall.add_argument("--path", help="Include the most recent scan of this path") + memory_recall.add_argument("--limit", type=int, default=20, help="Cap on recent events to return") + add_json(memory_recall) + + memory_record = memory_sub.add_parser("record", help="Record an applied refactor, ADR status change, or note") + memory_record.add_argument("--kind", required=True, choices=["applied", "decision", "note"]) + memory_record.add_argument("--pattern", help="kind=applied: catalog pattern slug that was applied") + memory_record.add_argument("--target", help="kind=applied: file or module the pattern was applied to") + memory_record.add_argument("--adr", type=int, help="kind=applied: ADR number to link; kind=decision: ADR number changing status") + memory_record.add_argument("--status", help="kind=decision: new status (accepted, superseded, deprecated)") + memory_record.add_argument("--outcome", default="done", choices=["done", "partial", "reverted"], help="kind=applied: outcome") + memory_record.add_argument("--summary", help="Short human-readable summary of what was done or decided") + memory_record.add_argument("--source-shape", dest="source_shape", help="kind=applied: code shape before the refactor") + memory_record.add_argument("--verified", action="store_true", help="kind=applied: change was verified by tests/runtime checks") + add_json(memory_record) + + memory_render = memory_sub.add_parser("render", help="Regenerate the decisions/ markdown and index.md from the journal") + add_json(memory_render) + args = parser.parse_args() as_json = args.json or getattr(args, "command_json", False) if args.command == "list": @@ -604,6 +767,8 @@ def main() -> int: return serve_workbench(args.host, args.port) if args.command == "mcp": return serve_stdio() + if args.command == "memory": + return memory_command(args, as_json) return 1 diff --git a/plugins/design-patterns/commands/patterns-history.md b/plugins/design-patterns/commands/patterns-history.md new file mode 100644 index 0000000..0fa80d6 --- /dev/null +++ b/plugins/design-patterns/commands/patterns-history.md @@ -0,0 +1,37 @@ +--- +description: Recall this project's pattern memory — prior scans, decisions, and applied refactors +argument-hint: "help | [\"\"] [--path ] [--limit ]" +--- + +# Patterns History + +Parse `$ARGUMENTS` into a `patterns_recall` MCP call. + +Help behavior: + +- `/patterns-history help`, `/patterns-history --help`, or `/patterns-history -h` returns help only. +- Help must include purpose, usage, options, examples, and the backing MCP tool. +- Do not call `patterns_recall` when the user asks for help. + +Argument mapping: + +- First quoted or unflagged text: `query` — optional architecture force or topic. When supplied, prior ADR decisions matching that force are surfaced first under `matchedDecisions`. +- `--path `: optional file or directory. When supplied, the most recent scan of that path is included under `lastScan`. +- `--limit `: optional cap on how many recent events to return. Defaults to 20. + +Inference behavior: + +- Every argument is optional. With no arguments, return the full memory summary for the project. +- Do not ask for arguments just because they are missing. +- The response reports whether memory is `project`-scoped (a `.claude/plugins/design-patterns/` journal committed in the current repo) or `global` (a per-user fallback used when the working directory is not a project). Lead the answer by stating which mode is in effect. + +Examples: + +```text +/patterns-history +/patterns-history "provider dispatch" +/patterns-history --path backend/app/providers +/patterns-history "duplicate delivery" --limit 40 +``` + +Report what the project already knows: project-vs-global mode, recorded ADR decisions and their statuses, patterns already applied and where, and recent scans and recommendations. When a `query` was supplied, lead with any matching prior decisions so the user builds on them instead of re-deciding. There is no `patterns_record` slash command — durable outcomes are written by the skills (`patterns memory record` / the `patterns_record` MCP tool), not by users. diff --git a/plugins/design-patterns/docs/adr/0001-pattern-memory.md b/plugins/design-patterns/docs/adr/0001-pattern-memory.md new file mode 100644 index 0000000..45c2c86 --- /dev/null +++ b/plugins/design-patterns/docs/adr/0001-pattern-memory.md @@ -0,0 +1,151 @@ +# ADR-0001: Cross-session pattern memory — a project-scoped, hybrid JSONL + Markdown journal + +## Status + +Accepted — 2026-05-14 + +## Context + +Through `0.8.6` the `design-patterns` plugin was **entirely stateless**. Every +MCP call reloaded the Markdown catalog and returned JSON; `/patterns-adr` +generated an ADR seed and printed it; `/patterns-scan` reported smells and +forgot them. Nothing it learned about a codebase survived the call. + +The ask (BDM-52): make the plugin *remember* — what smells it found, what was +recommended, what decisions were made, what refactors were actually applied — +and, crucially, **read that memory back** so future calls build on prior work +instead of repeating it. Memory that is only written is a log; memory that is +also read is what makes the plugin "smarter." + +Two shaping constraints emerged: + +1. **The MCP server never sees `Edit` / `Write`.** It only sees what it is + *asked*. So it can capture *advice* (a scan ran, an ADR was drafted) but not + whether a recommended refactor was actually *applied* — that happens through + Claude's edit tools, outside the plugin. +2. **The six skills drive the CLI, not the MCP tools.** Every `SKILL.md` + declares `allowed-tools: … Bash(patterns *)`. Any capture story that only + covered the MCP tools would miss all skill-driven work. + +## Decision + +A persistent **project journal** the plugin both writes to and reads from. + +### Where memory lives + +Project-scoped, at **`/.claude/plugins/design-patterns/`** in the +*consuming* project — not in the plugin's own checkout, and not (the sibling +`fleet` plugin's choice) under `${CLAUDE_PLUGIN_DATA}`. When the working +directory is not inside a project repo, it falls back to a per-user global +location keyed by a hash of the cwd. + +```text +/.claude/plugins/design-patterns/ +├── journal.jsonl append-only event stream — the source of truth +├── decisions/NNNN-slug.md rendered ADR markdown (derived) +├── index.md "patterns we have & where" (derived) +└── journal.err swallowed write errors (best-effort) +``` + +### Storage format — hybrid + +`journal.jsonl` is the **single source of truth** and the only thing code +mutates — append-only, one event per line (`scan`, `recommendation`, +`decision`, `applied`, `edit`). `decisions/NNNN-slug.md` and `index.md` are +*rendered* from it, deterministically, so a re-render is a git no-op. + +### Capture seam — three parts + +- **Auto-capture** inside `patterns_scan` / `patterns_adr` / `patterns_recommend` + (and their CLI equivalents) — records the *advice* at zero workflow friction. +- **An explicit `patterns_record` tool / `patterns memory record` CLI** the + skills call after a refactor actually lands — the only honest capture of an + *outcome*. +- **A `PostToolUse` hook** on `Edit` / `Write` / `MultiEdit` — a coarse "edit" + breadcrumb. It cannot know *which* pattern (intent is in the conversation, not + the tool input), so it complements `patterns_record`, never replaces it. + +### Recall + the smart loop + +`patterns_recall` (and `/patterns-history`) read the folded journal. The +existing tools consult it: `patterns_recommend` surfaces an already-`accepted` +ADR for the same force instead of re-deciding; `patterns_scan` diffs against the +last scan of that path; the `pattern-application` skill checks whether a module +already had a pattern applied. + +## Rationale + +### Why project-scoped, not `${CLAUDE_PLUGIN_DATA}` + +This is the deliberate inverse of `fleet`'s ADR-0002. `fleet` state is +*ephemeral session machinery* (tmux panes, PID locks, event offsets) that nobody +wants in their repo and that *should* die on `/plugin uninstall`. Design-pattern +memory is the opposite: **durable architectural knowledge** — "we chose Strategy +for provider dispatch, here is the ADR" — that must survive uninstall, be +code-reviewed in PRs, and travel to teammates. That is exactly what `docs/adr/` +directories exist for, and exactly what the user meant by "remember what *we* +have." The global fallback keeps ad-hoc CLI use working when there is no repo. + +### Why a hybrid JSONL + Markdown store + +- Append-only JSONL is lock-free and concurrency-safe; tailers tolerate partial + lines. Status changes are *new events*, never edits — no in-place mutation. +- Rendered Markdown ADRs are the human- and PR-facing surface and match the + plugin's Markdown-native catalog. +- A pure-JSONL store would lose the reviewable decision record; pure Markdown + would make status transitions an editing/merge problem; SQLite would put an + unreviewable binary in git. + +### Why the capture seam is three parts + +No single seam is sufficient. Auto-capture is friction-free but blind to +outcomes. The hook is automatic but semantically coarse. Only an explicit +skill-invoked `record` call captures "Strategy was applied to `providers/ai.py`, +verified, links ADR-3." All three together cover advice, file churn, and real +outcomes. + +### Why writers never raise + +A memory-write fault (a read-only dir, a full disk) must never break a +`patterns_*` tool call. Every writer routes through `_append`, which swallows +its own errors to `journal.err`; the MCP server wraps memory calls in +`_safe_memory` as a second layer; the hook's bash wrapper has a `trap 'exit 0'`. + +## Consequences + +### Positive + +- The plugin owns durable, reviewable, team-shared architectural memory that + survives `/plugin uninstall` and travels with the repo. +- Every surface — MCP tools, CLI, skills, the hook — feeds one journal. +- Existing tools get measurably smarter (prior decisions, scan diffs) with no + new user action required. + +### Negative + +- Adds a committed `.claude/plugins/design-patterns/` directory to consuming + repos. Teams that do not want it can `.gitignore` it; the plugin still works + (it just stops being team-shared). +- `render()` rewrites all decision files on every recorded decision/applied + event — O(n) per write. Acceptable: these are not hot paths and the journal + is small (tens to low-hundreds of events per project). + +### Neutral / operational + +- Memory resolution reads `$PWD`; outside a project repo it falls back to + `${CLAUDE_PLUGIN_DATA:-~/.claude/plugins/data/design-patterns}/projects//`. +- The plugin's own test suite (`tests/test_catalog.py`) isolates memory side + effects to a throwaway dir so test runs never touch a real journal. + +## References + +- BDM-52 — epic: onboard `design-patterns` to the marketplace + cross-session + pattern memory. +- BDM-54 — `lib/pattern_memory.py` core module. +- BDM-55 — MCP server integration (`patterns_record`, `patterns_recall`, + auto-capture, the recommend smart loop). +- BDM-56 — `patterns memory` CLI subcommands + the PostToolUse capture hook. +- BDM-57 — `/patterns-history` command + memory-aware skills. +- BDM-58 — Markdown ADR / index renderers + ADR status lifecycle. +- `fleet/docs/adr/0002-plugin-data-directory.md` — the contrasting decision to + put *session* state under `${CLAUDE_PLUGIN_DATA}`. diff --git a/plugins/design-patterns/docs/catalog-authoring.md b/plugins/design-patterns/docs/catalog-authoring.md new file mode 100644 index 0000000..6e2dea5 --- /dev/null +++ b/plugins/design-patterns/docs/catalog-authoring.md @@ -0,0 +1,119 @@ +# Catalog Authoring Guide + +The design-pattern plugin is source-neutral. Add catalog entries because they help architecture decisions, not because they come from a particular book, site, vendor, or framework. + +## Pattern Files + +Pattern files live in `plugins/design-patterns/data/patterns/*.md`. + +Required frontmatter: + +- `slug` +- `name` +- `domain` +- `category` +- `groups` +- `languages` +- `qualityAttributes` +- `implementationComplexity` +- `operationalRisk` +- `tradeoffs` +- `failureModes` +- `testingFocus` +- `observabilityFocus` +- `related` +- `relationships` +- `references` + +Required sections: + +- `Intent` +- `When To Use` +- `Avoid When` +- `Forces` +- `Tradeoffs` +- `Failure Modes` +- `Testing` +- `Observability` +- `Implementation Notes` + +Use typed relationships in `type:slug` format. Supported examples include `alternative`, `companion`, `often-confused-with`, `requires`, `enables`, and `mitigates`. + +## Playbooks + +Playbooks live in `plugins/design-patterns/data/playbooks/*.md`. Use playbooks when the answer should recommend a coherent pattern set rather than a single pattern. + +Required sections: + +- `Intent` +- `When To Use` +- `Avoid When` +- `Pattern Set` +- `Implementation Steps` +- `Verification` + +## Smells + +Smells live in `plugins/design-patterns/data/smells/*.md`. Use smells to detect architectural risk before prescribing a pattern. + +Required sections: + +- `Symptom` +- `Why It Matters` +- `Pattern Responses` +- `False Positives` +- `Checks` + +## Recipes + +Recipes live in `plugins/design-patterns/data/recipes/*.md`. Use recipes for step-by-step pattern application. + +Required sections: + +- `Goal` +- `Preconditions` +- `Steps` +- `Tests` +- `Rollback` + +## Framework Packs + +Framework packs live in `plugins/design-patterns/data/frameworks/*.md`. Use these for stack-specific implementation details. + +Required sections: + +- `Best For` +- `Pattern Mapping` +- `Implementation Notes` +- `Testing Guidance` +- `Operational Guidance` + +## Scorecards + +Scorecards live in `plugins/design-patterns/data/scorecards/*.md`. Use these when comparing architecture options. + +Required sections: + +- `Intent` +- `Scale` +- `Criteria` +- `Output Contract` +- `Anti-Patterns` + +## Validation + +Run these before committing: + +```bash +python3 scripts/validate_catalog.py +python3 -m unittest discover +python3 scripts/run_evals.py +python3 scripts/generate_site.py +plugins/design-patterns/bin/patterns serve --help +``` + +## Dynamic Workbench + +The browser workbench is a plugin capability, not a separate local-only site. Its implementation lives in `plugins/design-patterns/lib/pattern_workbench.py` and is launched through `plugins/design-patterns/bin/patterns serve`. + +The static generator also writes into the plugin bundle at `plugins/design-patterns/site`. When adding new catalog fields, update both the static generator and the dynamic workbench API if the field should be searchable, filterable, displayed in details, included in scenario recommendations, surfaced by paste-in architecture scans, used by implementation briefs, or included in ADR, matrix, and graph workflows. diff --git a/plugins/design-patterns/docs/classic-object-pattern-coverage.md b/plugins/design-patterns/docs/classic-object-pattern-coverage.md new file mode 100644 index 0000000..246df74 --- /dev/null +++ b/plugins/design-patterns/docs/classic-object-pattern-coverage.md @@ -0,0 +1,47 @@ +# Classic Object Pattern Coverage + +This is a source-neutral coverage audit for the object-design baseline commonly organized as creational, structural, and behavioral patterns. The catalog itself remains organized by domain, group, language, relationship, and implementation guidance rather than by provenance. + +## Python Coverage + +Python is already a first-class language profile: + +- `plugins/design-patterns/data/languages/python.md` +- every pattern in `plugins/design-patterns/data/patterns/*.md` includes `python` in its `languages` frontmatter +- `plugins/design-patterns/data/frameworks/python-celery-faststream.md` provides Python-specific integration implementation guidance + +## Creational + +- `abstract-factory` +- `builder` +- `factory-method` +- `prototype` +- `singleton` + +## Structural + +- `adapter` +- `bridge` +- `composite` +- `decorator` +- `facade` +- `flyweight` +- `proxy` + +## Behavioral + +- `chain-of-responsibility` +- `command` +- `interpreter` +- `iterator` +- `mediator` +- `memento` +- `observer` +- `state` +- `strategy` +- `template-method` +- `visitor` + +## Validation + +`scripts/validate_catalog.py` enforces that these object-design entries exist and that Python remains present across the pattern catalog. diff --git a/plugins/design-patterns/evals/evals.json b/plugins/design-patterns/evals/evals.json new file mode 100644 index 0000000..3849191 --- /dev/null +++ b/plugins/design-patterns/evals/evals.json @@ -0,0 +1,115 @@ +{ + "skill_name": "design-patterns", + "evals": [ + { + "id": 1, + "prompt": "We publish OrderCreated to billing, fulfillment, and analytics. Consumers can be down and messages may redeliver. Recommend an architecture decision with patterns, tradeoffs, and verification.", + "expected_output": "Uses an ADR-style decision, recommends event fanout with publish-subscribe, durable subscribers, idempotent receivers, correlation, dead-letter handling, and concrete duplicate/replay tests.", + "files": [], + "golden_output": "evals/golden/event-fanout-adr.md", + "assertions": [ + { + "type": "contains_sections", + "sections": ["Decision", "Alternatives Considered", "Consequences", "Verification"] + }, + { + "type": "contains_terms", + "terms": ["Publish-Subscribe Channel", "Idempotent Receiver", "Dead Letter Channel", "Correlation Identifier"] + } + ] + }, + { + "id": 2, + "prompt": "Our pricing service has repeated conditionals for customer type and market. Find the likely pattern and tell me when not to use it.", + "expected_output": "Shortlists Strategy, compares State or simpler function/table alternatives, explains forces, tradeoffs, implementation boundary, and behavior-focused tests.", + "files": [], + "golden_output": "evals/golden/strategy-shortlist.md", + "assertions": [ + { + "type": "contains_sections", + "sections": ["Best Fit", "Alternatives", "Do Not Use Yet", "Verification"] + }, + { + "type": "contains_terms", + "terms": ["Strategy", "State", "function map", "behavior tests"] + } + ] + }, + { + "id": 3, + "prompt": "Review this integration design: a worker retries failed messages forever and assumes exactly-once processing. Give findings and fixes.", + "expected_output": "Identifies unbounded retry and naive exactly-once smells, recommends idempotent receiver and dead-letter/expiration policy, and includes operational checks.", + "files": [], + "golden_output": "evals/golden/retry-smell-scan.md", + "assertions": [ + { + "type": "contains_sections", + "sections": ["Findings", "Pattern Response", "Verification"] + }, + { + "type": "contains_terms", + "terms": ["Unbounded Retry", "Naive Exactly Once", "Idempotent Receiver", "Dead Letter Channel"] + } + ] + }, + { + "id": 4, + "prompt": "Provider selection is leaking through domain services. Use the catalog to rank options and explain the fit.", + "expected_output": "Ranks the provider abstraction playbook first, includes the provider-switch smell, and explains why Bridge, Strategy, Adapter, and Abstract Factory are relevant.", + "files": [], + "golden_output": "evals/golden/provider-recommendation.md", + "command": ["bin/patterns", "recommend", "provider selection leaks into domain code", "--json", "--explain"], + "assertions": [ + { + "type": "contains_terms", + "terms": ["Provider Abstraction", "Bridge", "Strategy", "Adapter"] + }, + { + "type": "command_json_top_slug", + "path": "recommendations", + "slug": "provider-abstraction" + }, + { + "type": "command_json_contains", + "terms": ["provider-switch-sprawl", "whyMatched", "whyMightBeWrong"] + } + ] + }, + { + "id": 5, + "prompt": "Build a model-ready context pack from implementation evidence and pattern references for duplicate delivery.", + "expected_output": "Includes scan findings, recommended moves, implementation snippets, an ADR seed, and verification guidance.", + "files": [], + "golden_output": "evals/golden/context-pack.md", + "command": ["bin/patterns", "context", "data/playbooks/event-fanout.md", "--query", "duplicate delivery", "--language", "python", "--json"], + "assertions": [ + { + "type": "contains_sections", + "sections": ["Findings", "Recommended Moves", "Implementation Snippets", "ADR Seed", "Verification"] + }, + { + "type": "command_json_contains", + "terms": ["Pattern Context Pack", "python-idempotent-receiver", "recommendations", "adr"] + } + ] + }, + { + "id": 6, + "prompt": "Ask the relationship graph what mitigates naive exactly-once handling.", + "expected_output": "Answers with mitigations such as Idempotent Receiver, Guaranteed Delivery, Transactional Client, and Dead Letter Channel.", + "files": [], + "golden_output": "evals/golden/graph-query.md", + "command": ["bin/patterns", "graph", "--query", "what mitigates naive exactly once", "--json"], + "assertions": [ + { + "type": "contains_terms", + "terms": ["Idempotent Receiver", "Guaranteed Delivery", "Dead Letter Channel", "mitigates"] + }, + { + "type": "command_json_contains", + "terms": ["mitigates", "idempotent-receiver", "naive-exactly-once"] + } + ] + } + ] +} diff --git a/plugins/design-patterns/evals/golden/context-pack.md b/plugins/design-patterns/evals/golden/context-pack.md new file mode 100644 index 0000000..2ba9429 --- /dev/null +++ b/plugins/design-patterns/evals/golden/context-pack.md @@ -0,0 +1,19 @@ +# Pattern Context Pack + +## Request +Build a context pack for duplicate delivery handling. + +## Findings +The scanner should surface retry, duplicate delivery, or idempotency evidence when present. + +## Recommended Moves +Use Idempotent Receiver, Guaranteed Delivery, Dead Letter Channel, and related playbooks before scaling consumers. + +## Implementation Snippets +Include language-specific snippets such as the Python Idempotent Receiver when the language filter is Python. + +## ADR Seed +Summarize the architecture decision and alternatives. + +## Verification +Replay the same message twice, exhaust failure policy, and confirm recovery is observable. diff --git a/plugins/design-patterns/evals/golden/event-fanout-adr.md b/plugins/design-patterns/evals/golden/event-fanout-adr.md new file mode 100644 index 0000000..9bf7828 --- /dev/null +++ b/plugins/design-patterns/evals/golden/event-fanout-adr.md @@ -0,0 +1,20 @@ +# ADR: Use Event Fanout For OrderCreated Notifications + +## Decision +Use Event Message over a Publish-Subscribe Channel so billing, fulfillment, and analytics can consume OrderCreated independently. + +## Alternatives Considered +- Direct synchronous calls: simpler to trace but couples order creation to downstream availability. +- Request-reply workflow: useful only if order creation needs an immediate answer from each recipient. +- No pattern yet: not enough because consumers can be down and redelivery is expected. + +## Consequences +- Consumers own idempotency and retry behavior. +- The publisher owns the event contract and versioning. +- Operations owns dead-letter triage, replay, and support visibility. + +## Verification +- Duplicate delivery does not repeat unsafe side effects through Idempotent Receiver. +- A consumer outage can recover with Durable Subscriber behavior. +- Correlation Identifier appears in logs and traces. +- Dead Letter Channel has owner, alert, and replay procedure. diff --git a/plugins/design-patterns/evals/golden/graph-query.md b/plugins/design-patterns/evals/golden/graph-query.md new file mode 100644 index 0000000..2fce3e9 --- /dev/null +++ b/plugins/design-patterns/evals/golden/graph-query.md @@ -0,0 +1,12 @@ +# Graph Query + +## Answer +Idempotent Receiver mitigates Naive Exactly Once by making duplicate delivery safe. + +## Related Mitigations +- Guaranteed Delivery +- Transactional Client +- Dead Letter Channel + +## Verification +The graph response should include typed mitigates edges from pattern or playbook nodes to the Naive Exactly Once smell. diff --git a/plugins/design-patterns/evals/golden/provider-recommendation.md b/plugins/design-patterns/evals/golden/provider-recommendation.md new file mode 100644 index 0000000..548e758 --- /dev/null +++ b/plugins/design-patterns/evals/golden/provider-recommendation.md @@ -0,0 +1,15 @@ +# Provider Recommendation + +## Best Fit +Provider Abstraction is the first option when provider selection leaks into domain code. It keeps the domain contract separate from provider SDKs and leaves provider selection in composition or policy code. + +## Pattern Response +- Bridge separates abstraction from provider implementation. +- Strategy makes runtime selection explicit. +- Adapter protects the domain from provider SDK shape. +- Abstract Factory can keep compatible provider families together. + +## Verification +- Adding a provider does not change domain workflows. +- Provider-specific errors are mapped consistently. +- Selection policy is deterministic and observable. diff --git a/plugins/design-patterns/evals/golden/retry-smell-scan.md b/plugins/design-patterns/evals/golden/retry-smell-scan.md new file mode 100644 index 0000000..75f5eb8 --- /dev/null +++ b/plugins/design-patterns/evals/golden/retry-smell-scan.md @@ -0,0 +1,16 @@ +# Retry Smell Scan + +## Findings +- P1: Unbounded Retry. The worker retries forever without a terminal failure policy. +- P1: Naive Exactly Once. The worker assumes redelivery will not duplicate side effects. + +## Pattern Response +- Add Idempotent Receiver with a stable business or message key. +- Add Dead Letter Channel for exhausted failures. +- Add Message Expiration when stale work is no longer useful. +- Consider Guaranteed Delivery only with duplicate handling and support ownership. + +## Verification +- Duplicate delivery does not repeat side effects. +- Poison messages stop retrying and become visible. +- Retry count, terminal failure, and replay actions are observable. diff --git a/plugins/design-patterns/evals/golden/strategy-shortlist.md b/plugins/design-patterns/evals/golden/strategy-shortlist.md new file mode 100644 index 0000000..e4895d5 --- /dev/null +++ b/plugins/design-patterns/evals/golden/strategy-shortlist.md @@ -0,0 +1,17 @@ +# Pricing Variation Shortlist + +## Best Fit +Strategy is the best fit when pricing behavior varies by customer type and market while keeping the same input and output shape. + +## Alternatives +- State fits only if pricing behavior changes by lifecycle state. +- A function map is simpler when variants are small and first-class functions are idiomatic. +- A table-driven rule set is better when behavior is mostly data. + +## Do Not Use Yet +Do not introduce Strategy if there are only one or two trivial branches, if strategies need too much host object state, or if a simple function parameter communicates the variation better. + +## Verification +- Add behavior tests for each pricing variant. +- Add selection tests for customer type and market. +- Preserve existing outputs with parity tests before removing conditionals. diff --git a/plugins/design-patterns/hooks/event-emitter.sh b/plugins/design-patterns/hooks/event-emitter.sh new file mode 100755 index 0000000..ce457ca --- /dev/null +++ b/plugins/design-patterns/hooks/event-emitter.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# PostToolUse hook — drop a coarse "edit" breadcrumb into the design-patterns +# pattern-memory journal whenever Claude edits a file (Edit / Write / MultiEdit). +# +# Breadcrumb ONLY: this records THAT a file changed, not which pattern or +# decision — intent lives in the conversation, not the tool input. Real +# applied-refactor outcomes are captured by the pattern-application skill +# calling `patterns memory record` / the patterns_record MCP tool. +# See design-patterns/docs/adr/0001-pattern-memory.md (BDM-56). +# +# Hook contract: exit 0 ALWAYS — an observability hook must never block a tool +# call. pattern_memory's writers already swallow their own errors; the shell +# adds a trap + `|| true` so nothing here can ever surface non-zero. +# +# NOTE on notifications: Registering a PostToolUse hook causes the Claude Code +# host to surface a HookExecution / HookRunEntryDto notification (with the full +# event JSON and status Success) in the UI/activity feed every time it fires. +# This is by design for visibility ("It is triggered in the hooks"). The hook +# itself is silent (2>/dev/null || true) and only appends a coarse breadcrumb +# for pattern memory. If the notifications are noisy during heavy editing: +# - Temporarily disable by commenting the entry in the *installed* plugin's +# hooks/hooks.json (under ~/.claude/plugins/design-patterns/ or equivalent). +# It will reset on `/plugin update`. +# - Or run with env var DESIGN_PATTERNS_NO_EDIT_HOOK=1 to skip recording. +# For permanent change, edit here in the marketplace source and release new version. + +set -u +trap 'exit 0' ERR EXIT + +ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" + +# Pass the PostToolUse JSON straight through stdin to the recorder. It lives in +# a separate file rather than an inline heredoc on purpose — a `python3 - </dev/null || true +exit 0 diff --git a/plugins/design-patterns/hooks/hooks.json b/plugins/design-patterns/hooks/hooks.json new file mode 100755 index 0000000..6eabb64 --- /dev/null +++ b/plugins/design-patterns/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit|search_replace", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/event-emitter.sh", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/plugins/design-patterns/hooks/record_edit.py b/plugins/design-patterns/hooks/record_edit.py new file mode 100755 index 0000000..95f15b4 --- /dev/null +++ b/plugins/design-patterns/hooks/record_edit.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""PostToolUse recorder for the design-patterns pattern-memory hook. + +Reads a PostToolUse event from stdin; for an Edit/Write/MultiEdit tool call it +appends one coarse "edit" breadcrumb to the project's pattern-memory journal via +``pattern_memory.record_edit``. Invoked by ``hooks/event-emitter.sh``. + +Never raises, always exits 0 — an observability hook must not block a tool call. +See design-patterns/docs/adr/0001-pattern-memory.md (BDM-56). +""" + +from __future__ import annotations + +import json +import os +import sys + + +_ROOT = os.environ.get("DP_ROOT") or os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(_ROOT, "lib")) + + +def main() -> int: + if os.environ.get("DESIGN_PATTERNS_NO_EDIT_HOOK"): + return 0 + try: + raw = sys.stdin.read() + event = json.loads(raw) if raw.strip() else {} + tool = event.get("tool_name") or "" + if tool in ("Edit", "Write", "MultiEdit", "search_replace"): + file_path = (event.get("tool_input") or {}).get("file_path") + if file_path: + import pattern_memory + + pattern_memory.record_edit(file_path, tool) + except Exception: + pass # observability hook — never block a tool call + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/design-patterns/kimi.plugin.json b/plugins/design-patterns/kimi.plugin.json new file mode 100644 index 0000000..e7649be --- /dev/null +++ b/plugins/design-patterns/kimi.plugin.json @@ -0,0 +1,9 @@ +{ + "name": "design-patterns", + "version": "0.9.3", + "description": "Source-neutral advisor, Markdown reference catalog, MCP tooling, and dynamic workbench for software design patterns.", + "skills": "./skills", + "agents": "./agents", + "commands": "./commands", + "mcpServers": "./.portable-mcp.json" +} diff --git a/plugins/design-patterns/lib/pattern_mcp_server.py b/plugins/design-patterns/lib/pattern_mcp_server.py index 4d9da79..3bffedc 100644 --- a/plugins/design-patterns/lib/pattern_mcp_server.py +++ b/plugins/design-patterns/lib/pattern_mcp_server.py @@ -16,8 +16,10 @@ from pattern_intelligence import all_entries, adr_payload, find_entry, recommend_entries from pattern_scanner import scan_path +import pattern_memory -SERVER_INFO = {"name": "design-patterns", "version": "0.8.6"} + +SERVER_INFO = {"name": "design-patterns", "version": "0.9.3"} PLUGIN_ROOT = Path(__file__).resolve().parents[1] RISK_TERMS = { "operability": { @@ -261,6 +263,23 @@ ], "arguments": {}, }, + "patterns-history": { + "tool": "patterns_recall", + "purpose": "Recall this project's pattern memory — prior scans, recommendations, ADR decisions, and applied refactors. Consult before recommending or scanning so answers build on prior decisions instead of repeating them.", + "usage": '/patterns-history [""] [--path ] [--limit ]', + "helpCommand": "/patterns-history help", + "options": [ + "query: optional architecture force or topic; matching prior ADR decisions are surfaced first.", + "--path : optional file or directory; includes the most recent scan of that path.", + "--limit : optional cap on how many recent events to return. Defaults to 20.", + ], + "examples": [ + "/patterns-history", + '/patterns-history "provider dispatch"', + "/patterns-history --path backend/app/providers", + ], + "arguments": {}, + }, } SLASH_COMMAND_EXAMPLES: list[dict[str, Any]] = [ @@ -438,6 +457,36 @@ def tool_definitions() -> list[dict[str, Any]]: }, }, }, + { + "name": "patterns_record", + "description": "Record a durable pattern-memory outcome to the project journal: an applied refactor, an ADR status change, or a note. Skill-invoked — there is no slash command. The MCP server cannot see Edit/Write, so a skill calls this after a change actually lands.", + "inputSchema": { + "type": "object", + "properties": { + "kind": _arg("Record kind: applied (a refactor that landed), decision (an ADR status change), or note. Required."), + "pattern": _arg("For kind=applied: the catalog pattern slug that was applied, such as strategy or idempotent-receiver."), + "target": _arg("For kind=applied: the file or module the pattern was applied to."), + "adr": _arg("For kind=applied: an ADR number to link this refactor to. For kind=decision: the ADR number whose status is changing (required for kind=decision).", "integer"), + "status": _arg("For kind=decision: the new ADR status — accepted, superseded, or deprecated."), + "outcome": _arg("For kind=applied: done, partial, or reverted. Defaults to done when omitted."), + "summary": _arg("A short human-readable summary of what was done or decided."), + "sourceShape": _arg("For kind=applied: optional description of the code shape before the refactor."), + "verified": _arg("For kind=applied: whether the change was verified by tests or runtime checks.", "boolean"), + }, + }, + }, + { + "name": "patterns_recall", + "description": "Recall what this project's pattern memory already knows — prior scans, recommendations, ADR decisions, and applied refactors. User slash command: /patterns-history. Consult this BEFORE recommending or scanning so answers build on prior decisions instead of repeating them.", + "inputSchema": { + "type": "object", + "properties": { + "query": _arg("Optional architecture force or topic. When supplied, matching prior ADR decisions are surfaced first."), + "path": _arg("Optional file or directory. When supplied, the most recent scan of that path is included."), + "limit": _arg("Optional cap on how many recent events to return. Defaults to 20 when omitted.", "integer"), + }, + }, + }, ] @@ -972,6 +1021,36 @@ def _add_inference(payload: Any, inference: dict[str, Any]) -> Any: return payload +def _safe_memory(func: Any, *args: Any, **kwargs: Any) -> Any: + """Run a pattern_memory call without ever letting it break a tool call. The memory + writers already swallow their own I/O errors; this also guards the import boundary + and any argument-shaping mistakes.""" + try: + return func(*args, **kwargs) + except Exception: + return None + + +def _attach_prior_decisions(payload: dict[str, Any], query: str) -> None: + """Smart loop: surface already-recorded ADR decisions for the same force so the model + cites an existing decision instead of re-deciding from scratch.""" + prior = _safe_memory(pattern_memory.decisions_for_force, query) + if not prior: + return + payload["priorDecisions"] = prior + if any(decision.get("status") == "accepted" for decision in prior): + payload["memoryHint"] = ( + "This project already has an accepted ADR decision for a related force " + "(see priorDecisions). Lead with the existing decision; only recommend " + "something new if the user is explicitly reconsidering it." + ) + else: + payload["memoryHint"] = ( + "This project has prior ADR decisions for a related force (see priorDecisions) " + "— reference them rather than starting from zero." + ) + + def call_tool(name: str, arguments: dict[str, Any]) -> Any: if name == "patterns_help": resolution = _new_resolution(name) @@ -1010,17 +1089,17 @@ def call_tool(name: str, arguments: dict[str, Any]) -> Any: limit=limit, include_snippets=True, ) - return _with_resolution( - { - "query": query, - "language": inference.get("language"), - "scope": inference.get("scope") or "all", - "risk": risk, - "limit": limit, - "recommendations": recommendations, - }, - resolution, - ) + payload = { + "query": query, + "language": inference.get("language"), + "scope": inference.get("scope") or "all", + "risk": risk, + "limit": limit, + "recommendations": recommendations, + } + _attach_prior_decisions(payload, query) + _safe_memory(pattern_memory.record_recommendation, payload) + return _with_resolution(payload, resolution) if name == "patterns_scan": resolution = _new_resolution(name) path = _resolve_path(arguments, resolution, purpose="architecture smell scanning") @@ -1037,19 +1116,26 @@ def call_tool(name: str, arguments: dict[str, Any]) -> Any: min_confidence = _resolve_float(arguments, resolution, "min_confidence", default=0.0, minimum=0.0, maximum=1.0) if _has_blockers(resolution): return _missing_response(name, resolution) - return _with_resolution( - _add_inference( - scan_path( - path, - pack=pack, - include_docs=include_docs, - include_generated=include_generated, - min_confidence=min_confidence, - ), - inference, - ), - resolution, + scan_result = scan_path( + path, + pack=pack, + include_docs=include_docs, + include_generated=include_generated, + min_confidence=min_confidence, + ) + payload = _add_inference(scan_result, inference) + # Smart loop: diff against the last stored scan of this path *before* recording. + diff = _safe_memory(pattern_memory.scan_diff, path, scan_result) + if diff is not None: + payload["memoryDiff"] = diff + _safe_memory( + pattern_memory.record_scan, + scan_result, + path=path, + pack=pack, + min_confidence=min_confidence, ) + return _with_resolution(payload, resolution) if name == "patterns_adr": resolution = _new_resolution(name) query = _resolve_text_argument( @@ -1064,18 +1150,24 @@ def call_tool(name: str, arguments: dict[str, Any]) -> Any: status = _resolve_status(arguments, resolution, query) if _has_blockers(resolution): return _missing_response(name, resolution) - return _with_resolution( - _add_inference( - adr_payload( - query, - status=status, - language=inference.get("language") or None, - scope=str(inference.get("scope") or "all"), - ), - inference, - ), - resolution, + scope = str(inference.get("scope") or "all") + adr_result = adr_payload( + query, + status=status, + language=inference.get("language") or None, + scope=scope, + ) + payload = _add_inference(adr_result, inference) + recorded = _safe_memory( + pattern_memory.record_decision, + adr_result, + status=status, + scope=scope, + language=inference.get("language") or None, ) + if isinstance(recorded, dict) and isinstance(recorded.get("adrNumber"), int): + payload["adrNumber"] = recorded["adrNumber"] + return _with_resolution(payload, resolution) if name == "patterns_context": resolution = _new_resolution(name) path = _resolve_path(arguments, resolution, purpose="context-pack generation") @@ -1182,6 +1274,62 @@ def call_tool(name: str, arguments: dict[str, Any]) -> Any: }, resolution, ) + if name == "patterns_record": + resolution = _new_resolution(name) + kind = _resolve_text_argument( + arguments, + resolution, + "kind", + aliases=("type",), + required_reason="patterns_record needs a record kind: applied, decision, or note.", + how_to_provide='patterns_record {"kind": "applied", "pattern": "strategy", "target": "src/providers.py"}', + ) + kind_value = str(kind or "").strip().lower() + if kind_value == "applied": + if _is_blank(arguments.get("pattern")): + _record( + resolution, + "missing", + "pattern", + whyNotInferable="An applied refactor must name the catalog pattern slug that was applied.", + howToProvide='Pass pattern, e.g. {"kind":"applied","pattern":"strategy","target":"src/providers.py"}.', + ) + if _is_blank(arguments.get("target")): + _record( + resolution, + "missing", + "target", + whyNotInferable="An applied refactor must name the file or module it was applied to.", + howToProvide='Pass target, e.g. {"kind":"applied","pattern":"strategy","target":"src/providers.py"}.', + ) + elif kind_value == "decision": + if arguments.get("adr") is None: + _record( + resolution, + "missing", + "adr", + whyNotInferable="A decision record must reference the ADR number whose status is changing.", + howToProvide='Pass adr, e.g. {"kind":"decision","adr":3,"status":"accepted"}.', + ) + if _has_blockers(resolution): + return _missing_response(name, resolution) + result = _safe_memory(pattern_memory.record_from_tool, kind, arguments) + if result is None: + result = {"recorded": False, "error": "pattern memory is unavailable"} + return _with_resolution(result, resolution) + if name == "patterns_recall": + resolution = _new_resolution(name) + query = str(_first_argument(arguments, "query", "topic", "force") or "").strip() + path = str(_first_argument(arguments, "path", "file", "directory") or "").strip() + if query: + _record(resolution, "provided", "query", query) + if path: + _record(resolution, "provided", "path", path) + limit = _resolve_limit(arguments, resolution, default=20) + summary = _safe_memory(pattern_memory.recall_summary, query or None, path or None, limit) + if summary is None: + summary = {"error": "pattern memory is unavailable", "decisions": [], "recentEvents": []} + return _with_resolution(summary, resolution) raise ValueError(f"Unknown tool: {name}") diff --git a/plugins/design-patterns/lib/pattern_memory.py b/plugins/design-patterns/lib/pattern_memory.py new file mode 100644 index 0000000..84ce4de --- /dev/null +++ b/plugins/design-patterns/lib/pattern_memory.py @@ -0,0 +1,809 @@ +"""Cross-session pattern memory: a persistent project journal the plugin reads and writes. + +The catalog itself is static and stateless. This module adds the *memory* layer — an +append-only JSONL journal of what the plugin found (scans), recommended, decided (ADRs), +and what refactors were actually applied — plus the readers that fold that journal back +into a current picture so the other tools can answer "what do we already have / know". + +Storage (per the consuming project, not the plugin): + + /.claude/plugins/design-patterns/ + ├── journal.jsonl append-only event stream — the source of truth + ├── decisions/ rendered ADR markdown (Phase 5 / BDM-58) + ├── index.md derived "patterns we have & where" (Phase 5 / BDM-58) + └── journal.err swallowed write errors (best-effort) + +When the working directory is not inside a project repo, the journal falls back to a +per-user global location keyed by a hash of the cwd. + +Design rules (mirroring ``pattern_catalog.py``): module-level constants, pure functions, +plain dicts, no classes, no caching. One extra invariant unique to this module — **writers +never raise**; a memory fault must never break a tool call, so failures are swallowed to +``journal.err`` on a best-effort basis. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import uuid +from collections.abc import Iterable +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] +SCHEMA_VERSION = 1 + +JOURNAL_FILE = "journal.jsonl" +DECISIONS_DIRNAME = "decisions" +INDEX_FILE = "index.md" +ERROR_FILE = "journal.err" + +# A project root is the nearest ancestor carrying one of these markers. +PROJECT_MARKERS = ( + ".git", + "pyproject.toml", + "package.json", + "go.mod", + "Cargo.toml", + "pom.xml", + "build.gradle", + "tsconfig.json", +) + +KNOWN_KINDS = {"scan", "recommendation", "decision", "applied", "edit", "note"} +DECISION_STATUSES = {"proposed", "accepted", "superseded", "deprecated"} +_STATUS_ALIASES = { + "accept": "accepted", + "supersede": "superseded", + "superceded": "superseded", + "deprecate": "deprecated", +} +APPLIED_OUTCOMES = {"done", "partial", "reverted"} +_STATUS_RANK = {"accepted": 0, "proposed": 1, "superseded": 2, "deprecated": 3} + + +# --------------------------------------------------------------------------- # +# Path resolution +# --------------------------------------------------------------------------- # +def _plugin_root() -> Path: + env_root = ( + os.environ.get("CLAUDE_PLUGIN_ROOT") + or os.environ.get("CODEX_PLUGIN_ROOT") + or os.environ.get("PLUGIN_ROOT") + ) + if env_root: + return Path(env_root).expanduser().resolve() + return PLUGIN_ROOT + + +def _inside(path: Path, parent: Path) -> bool: + try: + path.resolve().relative_to(parent.resolve()) + return True + except (ValueError, OSError): + return False + + +def _cwd() -> Path: + return Path(os.environ.get("PWD") or Path.cwd()) + + +def find_project_root(start: Path | None = None) -> Path | None: + """Nearest ancestor of ``start`` (default: $PWD/cwd) carrying a project marker. + + Returns ``None`` when there is no such ancestor or when ``start`` is inside the + plugin's own checkout — memory must never be written into the plugin directory. + """ + base = start or _cwd() + try: + base = base.expanduser().resolve() + except OSError: + return None + if not base.exists() or _inside(base, _plugin_root()): + return None + current = base if base.is_dir() else base.parent + for parent in [current, *current.parents]: + if any((parent / marker).exists() for marker in PROJECT_MARKERS): + return parent + return None + + +def _resolve() -> tuple[Path, str, Path | None]: + """Return ``(journal_dir, mode, anchor)``. + + ``mode`` is ``"project"`` or ``"global"``. ``anchor`` is the project root in project + mode (used to relativise stored paths), ``None`` in global mode. + """ + project = find_project_root() + if project is not None: + return project / ".claude" / "plugins" / "design-patterns", "project", project + base = os.environ.get("CLAUDE_PLUGIN_DATA") + root = ( + Path(base).expanduser() + if base + else Path.home() / ".claude" / "plugins" / "data" / "design-patterns" + ) + key = hashlib.sha256(str(_cwd()).encode("utf-8")).hexdigest()[:12] + return root / "projects" / key, "global", None + + +def journal_root() -> tuple[Path, str]: + """Public ``(journal_dir, mode)`` — never creates the directory.""" + journal_dir, mode, _ = _resolve() + return journal_dir, mode + + +def journal_path() -> Path: + return journal_root()[0] / JOURNAL_FILE + + +def journal_location() -> dict[str, Any]: + """Describe where memory lives — for ``patterns_recall`` and ``bin/patterns memory where``.""" + journal_dir, mode, anchor = _resolve() + journal = journal_dir / JOURNAL_FILE + return { + "mode": mode, + "root": str(journal_dir), + "journal": str(journal), + "exists": journal.exists(), + "projectRoot": str(anchor) if anchor is not None else None, + } + + +# --------------------------------------------------------------------------- # +# Small helpers +# --------------------------------------------------------------------------- # +def _now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _rel_to(anchor: Path | None) -> str: + cwd = _cwd() + if anchor is not None: + try: + rel = cwd.resolve().relative_to(anchor.resolve()) + return str(rel) or "." + except (ValueError, OSError): + pass + return str(cwd) + + +def _normalize_path(value: Any) -> str: + """Project-relative when possible, else absolute — so the same target is comparable + across runs regardless of the cwd a tool happened to be invoked from.""" + raw = Path(str(value)).expanduser() + if not raw.is_absolute(): + raw = _cwd() / raw + try: + raw = raw.resolve() + except OSError: + return str(value) + _, _, anchor = _resolve() + if anchor is not None: + try: + return str(raw.relative_to(anchor.resolve())) + except ValueError: + pass + return str(raw) + + +def _normalize_status(status: Any) -> str: + value = str(status or "").strip().lower() + if value in DECISION_STATUSES: + return value + return _STATUS_ALIASES.get(value, "proposed") + + +def _slug_list(value: Any) -> list[str] | None: + """Best-effort sorted slug list from a scan_path 'patterns' field, whatever its shape — + a list of slug strings, a list of entry dicts, or a slug->entry mapping.""" + if not value: + return None + items = value.values() if isinstance(value, dict) else value + slugs: set[str] = set() + for item in items: + if isinstance(item, str): + slugs.add(item) + elif isinstance(item, dict): + slug = item.get("slug") or item.get("pattern") or item.get("name") + if slug: + slugs.add(str(slug)) + return sorted(slugs) or None + + +def _pattern_known(slug: str) -> bool | None: + """Best-effort: is ``slug`` a real catalog pattern? ``None`` if the catalog can't load.""" + try: + from pattern_catalog import load_patterns + + return slug in {pattern.get("slug") for pattern in load_patterns()} + except Exception: + return None + + +# --------------------------------------------------------------------------- # +# Writers — these never raise +# --------------------------------------------------------------------------- # +def _record_error(journal_dir: Path, kind: str, exc: Exception) -> None: + try: + journal_dir.mkdir(parents=True, exist_ok=True) + with (journal_dir / ERROR_FILE).open("a", encoding="utf-8") as handle: + handle.write(f"{_now_iso()}\t{kind}\t{exc!r}\n") + except Exception: + pass # truly best-effort — there is nowhere left to report to + + +def _append(kind: str, payload: dict[str, Any], *, tool: str | None = None) -> dict[str, Any]: + """Append one event to the journal. Never raises; returns the written record, or a + ``{"recorded": False, ...}`` stub if the write failed.""" + journal_dir, _, anchor = _resolve() + record: dict[str, Any] = { + "schemaVersion": SCHEMA_VERSION, + "id": uuid.uuid4().hex, + "ts": _now_iso(), + "kind": kind, + "tool": tool, + "cwd": _rel_to(anchor), + } + for key, value in payload.items(): + if value is not None: + record[key] = value + try: + journal_dir.mkdir(parents=True, exist_ok=True) + line = json.dumps(record, separators=(",", ":"), sort_keys=True) + with (journal_dir / JOURNAL_FILE).open("a", encoding="utf-8") as handle: + handle.write(line + "\n") + handle.flush() + return record + except Exception as exc: # a memory write must never break a tool call + _record_error(journal_dir, kind, exc) + return {"recorded": False, "error": str(exc), **record} + + +def next_adr_number() -> int: + numbers = [ + decision.get("adrNumber") + for decision in current_decisions() + if isinstance(decision.get("adrNumber"), int) + ] + return max(numbers) + 1 if numbers else 1 + + +def record_scan( + scan_result: dict[str, Any], + *, + path: Any, + pack: Any = None, + min_confidence: float = 0.0, +) -> dict[str, Any]: + """Capture a ``patterns_scan`` result as a compact ``scan`` event (evidence snippets + are dropped — the journal stays small; the smells and their locations are kept).""" + findings = scan_result.get("findings", []) or [] + compact = [ + { + "smell": finding.get("smell"), + "severity": finding.get("severity"), + "file": finding.get("file"), + "line": finding.get("line"), + "confidence": finding.get("confidence"), + } + for finding in findings + ] + smell_counts: dict[str, int] = {} + for finding in compact: + slug = finding.get("smell") + if slug: + smell_counts[slug] = smell_counts.get(slug, 0) + 1 + return _append( + "scan", + { + "path": _normalize_path(path), + "pack": pack, + "minConfidence": min_confidence, + "findingCount": len(compact), + "findings": compact, + "smellCounts": smell_counts, + "patternSlugs": _slug_list(scan_result.get("patterns")), + }, + tool="patterns_scan", + ) + + +def record_recommendation(payload: dict[str, Any]) -> dict[str, Any]: + """Capture a ``patterns_recommend`` result as a compact ``recommendation`` event.""" + recommendations = payload.get("recommendations", []) or [] + top = [rec.get("slug") for rec in recommendations if rec.get("slug")][:8] + return _append( + "recommendation", + { + "query": payload.get("query"), + "scope": payload.get("scope"), + "language": payload.get("language"), + "risk": payload.get("risk"), + "topSlugs": top or None, + "primarySlug": top[0] if top else None, + }, + tool="patterns_recommend", + ) + + +def record_decision( + adr_result: dict[str, Any], + *, + status: Any = None, + scope: Any = None, + language: Any = None, +) -> dict[str, Any]: + """Capture a freshly generated ADR (from ``patterns_adr``). Allocates a new + ``adrNumber`` and re-renders the decision files.""" + chosen = adr_result.get("recommendedEntry") or {} + alternatives = adr_result.get("alternatives", []) or [] + number = next_adr_number() + record = _append( + "decision", + { + "adrNumber": number, + "title": adr_result.get("title"), + "status": _normalize_status(status or adr_result.get("status")), + "context": adr_result.get("context"), + "chosenSlug": chosen.get("slug"), + "alternativeSlugs": [alt.get("slug") for alt in alternatives if alt.get("slug")] or None, + "scope": scope, + "language": language, + }, + tool="patterns_adr", + ) + render() + return record + + +def record_decision_status( + adr_number: Any, + status: Any, + *, + summary: Any = None, + supersedes: Any = None, +) -> dict[str, Any]: + """Record an ADR lifecycle transition (accepted / superseded / deprecated) against an + existing ``adrNumber``. ``current_decisions()`` field-merges these onto the original.""" + try: + number = int(adr_number) + except (TypeError, ValueError): + return {"recorded": False, "error": f"adr must be an integer, got {adr_number!r}"} + payload: dict[str, Any] = {"adrNumber": number, "status": _normalize_status(status)} + if summary: + payload["summary"] = summary + if supersedes is not None: + try: + payload["supersedes"] = int(supersedes) + except (TypeError, ValueError): + pass + record = _append("decision", payload, tool="patterns_record") + render() + return record + + +def record_applied( + *, + pattern: Any, + target: Any, + adr: Any = None, + source_shape: Any = None, + outcome: Any = "done", + summary: Any = None, + verified: Any = False, + notes: Any = None, +) -> dict[str, Any]: + """Record a real applied refactor. This is the only honest capture of an *outcome*: + the MCP server never sees Edit/Write, so a skill calls this after the change lands.""" + slug = str(pattern or "").strip() + if not slug or not str(target or "").strip(): + return {"recorded": False, "error": "record_applied requires both 'pattern' and 'target'"} + known = _pattern_known(slug) + linked_adr: int | None = None + if adr is not None: + try: + linked_adr = int(adr) + except (TypeError, ValueError): + linked_adr = None + record = _append( + "applied", + { + "pattern": slug, + "patternKnown": known, + "targetModule": _normalize_path(target), + "linkedAdr": linked_adr, + "sourceShape": source_shape, + "outcome": str(outcome).strip().lower() if str(outcome).strip().lower() in APPLIED_OUTCOMES else "done", + "summary": summary, + "verified": bool(verified), + "notes": notes, + }, + tool="patterns_record", + ) + render() + return record + + +def record_from_tool(kind: Any, arguments: dict[str, Any]) -> dict[str, Any]: + """Dispatch for the ``patterns_record`` MCP tool / ``patterns memory record`` CLI.""" + kind = str(kind or "").strip().lower() + if kind == "applied": + return record_applied( + pattern=arguments.get("pattern"), + target=arguments.get("target") or arguments.get("targetModule"), + adr=arguments.get("adr"), + source_shape=arguments.get("sourceShape") or arguments.get("source_shape"), + outcome=arguments.get("outcome") or "done", + summary=arguments.get("summary"), + verified=arguments.get("verified", False), + notes=arguments.get("notes"), + ) + if kind == "decision": + adr = arguments.get("adr") + if adr is None: + return {"recorded": False, "error": "decision records require an 'adr' number"} + return record_decision_status( + adr, + arguments.get("status") or "accepted", + summary=arguments.get("summary"), + supersedes=arguments.get("supersedes"), + ) + if kind == "note": + target = arguments.get("target") + return _append( + "note", + { + "summary": arguments.get("summary"), + "target": _normalize_path(target) if target else None, + }, + tool="patterns_record", + ) + return {"recorded": False, "error": f"unknown record kind: {kind!r} (expected applied, decision, or note)"} + + +def record_edit(file_path: Any, tool: str | None = None) -> dict[str, Any]: + """Coarse breadcrumb from the PostToolUse hook — records THAT a file changed, not + which pattern or decision (intent lives in the conversation, not the tool input). + Real applied-refactor outcomes come through record_applied.""" + return _append( + "edit", + {"file": _normalize_path(file_path), "editTool": tool}, + tool=tool or "edit-hook", + ) + + +# --------------------------------------------------------------------------- # +# Readers / folders +# --------------------------------------------------------------------------- # +def iter_events(kinds: Iterable[str] | None = None) -> list[dict[str, Any]]: + """All journal events in append order. Tolerates malformed/partial lines and a + missing journal (returns ``[]`` — the read path never creates the directory).""" + journal = journal_path() + if not journal.exists(): + return [] + wanted = set(kinds) if kinds is not None else None + events: list[dict[str, Any]] = [] + try: + with journal.open("r", encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except Exception: + continue # tailers see partial writes mid-flush — skip, don't fail + if not isinstance(event, dict): + continue + if wanted is None or event.get("kind") in wanted: + events.append(event) + except OSError: + return [] + return events + + +def current_decisions() -> list[dict[str, Any]]: + """Fold ``decision`` events by ``adrNumber`` — later events field-merge onto earlier + ones, so a minimal status-transition keeps the original title/context while the + latest status wins. ``createdTs`` preserves the first event's timestamp; ``ts`` is + the latest touch. Sorted by ``adrNumber``.""" + folded: dict[int, dict[str, Any]] = {} + for event in iter_events(("decision",)): + number = event.get("adrNumber") + if not isinstance(number, int): + continue + existing = folded.get(number) + merged = dict(existing) if existing else {} + for key, value in event.items(): + if value is not None: + merged[key] = value + merged["adrNumber"] = number + merged["createdTs"] = existing.get("createdTs") if existing else event.get("ts") + folded[number] = merged + return [folded[number] for number in sorted(folded)] + + +def decisions_for_force(query: str, limit: int = 5) -> list[dict[str, Any]]: + """Stored decisions whose title/context/chosen pattern overlap ``query`` — accepted + decisions first. This is what lets ``patterns_recommend`` say "you already decided X".""" + if not query: + return [] + try: + from pattern_intelligence import tokenize + + terms = set(tokenize(query)) + except Exception: + terms = {token for token in re.findall(r"[a-z0-9-]+", str(query).casefold()) if len(token) > 2} + if not terms: + return [] + scored: list[tuple[int, int, dict[str, Any]]] = [] + for decision in current_decisions(): + haystack = " ".join( + str(decision.get(key) or "") for key in ("title", "context", "chosenSlug") + ).casefold() + hay_terms = set(re.findall(r"[a-z0-9-]+", haystack)) + overlap = len(terms & hay_terms) + if overlap == 0: + continue + status_rank = _STATUS_RANK.get(decision.get("status", "proposed"), 4) + scored.append((status_rank, -overlap, decision)) + scored.sort(key=lambda item: (item[0], item[1])) + return [decision for _, _, decision in scored[:limit]] + + +def _finding_keys(findings: Iterable[dict[str, Any]]) -> set[str]: + """Identity of a finding for diffing — keyed by smell + file (line numbers drift).""" + keys: set[str] = set() + for finding in findings or []: + smell = finding.get("smell") + if smell: + keys.add(f"{smell} @ {finding.get('file') or '?'}") + return keys + + +def last_scan(path: Any) -> dict[str, Any] | None: + normalized = _normalize_path(path) + matches = [event for event in iter_events(("scan",)) if event.get("path") == normalized] + return matches[-1] if matches else None + + +def scan_diff(path: Any, current_result: dict[str, Any]) -> dict[str, Any]: + """Diff a fresh scan against the last stored scan of the same path — the engine + behind ``patterns_scan``'s "3 new smells, 2 resolved since ".""" + current = _finding_keys(current_result.get("findings", []) or []) + previous_event = last_scan(path) + if previous_event is None: + return { + "comparedToPrevious": False, + "newSmells": sorted(current), + "resolvedSmells": [], + "unchanged": 0, + } + previous = _finding_keys(previous_event.get("findings", []) or []) + return { + "comparedToPrevious": True, + "since": previous_event.get("ts"), + "newSmells": sorted(current - previous), + "resolvedSmells": sorted(previous - current), + "unchanged": len(current & previous), + } + + +def applied_for_module(module: Any) -> list[dict[str, Any]]: + """Applied-refactor events touching ``module`` (exact, or a parent/child directory).""" + normalized = _normalize_path(module) + results: list[dict[str, Any]] = [] + for event in iter_events(("applied",)): + target = event.get("targetModule") or "" + if ( + target == normalized + or target.startswith(normalized.rstrip("/") + "/") + or normalized.startswith(target.rstrip("/") + "/") + ): + results.append(event) + return results + + +def pattern_index() -> dict[str, dict[str, Any]]: + """Derived "what patterns we have and where" — folds ``applied`` events into + ``{slug: {modules, decisions, lastTouched, count}}``.""" + index: dict[str, dict[str, Any]] = {} + for event in iter_events(("applied",)): + slug = event.get("pattern") + if not slug: + continue + entry = index.setdefault( + slug, {"modules": [], "decisions": [], "lastTouched": None, "count": 0} + ) + entry["count"] += 1 + target = event.get("targetModule") + if target and target not in entry["modules"]: + entry["modules"].append(target) + adr = event.get("linkedAdr") + if isinstance(adr, int) and adr not in entry["decisions"]: + entry["decisions"].append(adr) + ts = event.get("ts") + if ts and (entry["lastTouched"] is None or ts > entry["lastTouched"]): + entry["lastTouched"] = ts + for entry in index.values(): + entry["modules"].sort() + entry["decisions"].sort() + return index + + +def _recent(events: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]: + """Most-recent-first event list for recall, with bulky scan ``findings`` trimmed + (the full findings stay in the journal and are reachable via ``last_scan``).""" + window = events[-limit:] if limit and limit > 0 else events + trimmed: list[dict[str, Any]] = [] + for event in reversed(window): + if event.get("kind") == "scan" and "findings" in event: + trimmed.append({key: value for key, value in event.items() if key != "findings"}) + else: + trimmed.append(dict(event)) + return trimmed + + +def recall_summary( + query: str | None = None, + path: Any = None, + limit: int = 20, +) -> dict[str, Any]: + """One-stop memory payload for ``patterns_recall`` / ``/patterns-history`` / + ``bin/patterns memory recall``.""" + journal_dir, mode, anchor = _resolve() + events = iter_events() + decisions = current_decisions() + applied = [event for event in events if event.get("kind") == "applied"] + summary: dict[str, Any] = { + "mode": mode, + "root": str(journal_dir), + "projectRoot": str(anchor) if anchor is not None else None, + "journalExists": (journal_dir / JOURNAL_FILE).exists(), + "eventCount": len(events), + "decisionCount": len(decisions), + "appliedCount": len(applied), + "decisions": decisions, + "patternIndex": pattern_index(), + "recentEvents": _recent(events, limit), + } + if query: + summary["query"] = query + summary["matchedDecisions"] = decisions_for_force(query) + if path: + summary["path"] = _normalize_path(path) + summary["lastScan"] = last_scan(path) + return summary + + +# --------------------------------------------------------------------------- # +# Renderers +# --------------------------------------------------------------------------- # +def _slugify(text: Any) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", str(text or "").casefold()).strip("-") + return slug or "decision" + + +def _decision_filename(decision: dict[str, Any]) -> str: + number = int(decision.get("adrNumber", 0)) + base = decision.get("chosenSlug") or _slugify(decision.get("title")) + return f"{number:04d}-{_slugify(base)}.md" + + +def _render_decision(decision: dict[str, Any]) -> str: + number = int(decision.get("adrNumber", 0)) + status = decision.get("status", "proposed") + title = decision.get("title") or f"Decision {number}" + chosen = decision.get("chosenSlug") + alternatives = decision.get("alternativeSlugs") or [] + supersedes = decision.get("supersedes") + lines = [ + "---", + f"number: {number}", + f"status: {status}", + f"chosen: {chosen or ''}", + f"supersedes: {supersedes if supersedes is not None else ''}", + f"created: {decision.get('createdTs') or ''}", + f"updated: {decision.get('ts') or ''}", + "---", + "", + f"# ADR-{number:04d}: {title}", + "", + f"**Status:** {status}", + ] + if supersedes is not None: + lines.append(f"**Supersedes:** ADR-{int(supersedes):04d}") + lines += [ + "", + "## Context", + "", + decision.get("context") or "_No context recorded._", + "", + "## Decision", + "", + f"Adopt **{chosen}** — see `patterns show {chosen}`." + if chosen + else "_No catalog pattern was chosen; keep the design reversible until the force is clearer._", + ] + if alternatives: + lines += ["", "## Alternatives considered", ""] + lines += [f"- `{slug}`" for slug in alternatives] + if decision.get("summary"): + lines += ["", "## Notes", "", str(decision["summary"])] + footer = [bit for bit in ( + f"scope: {decision['scope']}" if decision.get("scope") else "", + f"language: {decision['language']}" if decision.get("language") else "", + ) if bit] + suffix = f" ({', '.join(footer)})" if footer else "" + lines += ["", "---", "", f"_Recorded by design-patterns pattern memory{suffix}._", ""] + return "\n".join(lines) + + +def _render_index(decisions: list[dict[str, Any]], index: dict[str, dict[str, Any]], mode: str) -> str: + lines = [ + "# Design pattern memory", + "", + f"_Auto-generated by the design-patterns plugin ({mode} memory). Do not edit by hand —", + "regenerated from `journal.jsonl` on every recorded decision or applied refactor._", + "", + "## Decisions", + "", + ] + if decisions: + for decision in decisions: + number = int(decision.get("adrNumber", 0)) + lines.append( + f"- [ADR-{number:04d}]({DECISIONS_DIRNAME}/{_decision_filename(decision)}) — " + f"**{decision.get('status')}** — {decision.get('title') or ''}".rstrip() + ) + else: + lines.append("_No decisions recorded yet._") + lines += ["", "## Patterns in this codebase", ""] + if index: + for slug in sorted(index): + info = index[slug] + modules = ", ".join(f"`{module}`" for module in info.get("modules", [])) + adrs = ", ".join(f"ADR-{int(number):04d}" for number in info.get("decisions", [])) + suffix = f" (decisions: {adrs})" if adrs else "" + lines.append(f"- **{slug}** — applied in {modules or '_(no module recorded)_'}{suffix}") + else: + lines.append("_No applied patterns recorded yet._") + lines.append("") + return "\n".join(lines) + + +def render() -> None: + """Regenerate ``decisions/NNNN-slug.md`` and ``index.md`` from the journal. + + Deterministic — re-rendering without new events produces byte-identical files, so a + re-render is a git no-op. Called by the decision/applied writers; like them it never + raises — rendered files are derived state and a render fault must not break a tool + call. The JSONL journal remains the source of truth. + """ + try: + journal_dir, mode, _ = _resolve() + if not (journal_dir / JOURNAL_FILE).exists(): + return # nothing recorded yet — nothing to render + decisions = current_decisions() + index = pattern_index() + decisions_dir = journal_dir / DECISIONS_DIRNAME + if decisions: + decisions_dir.mkdir(parents=True, exist_ok=True) + written: set[str] = set() + for decision in decisions: + name = _decision_filename(decision) + (decisions_dir / name).write_text(_render_decision(decision), encoding="utf-8") + written.add(name) + # Prune stale decision files (e.g. a folded title change moved the slug). + for existing in decisions_dir.glob("*.md"): + if existing.name not in written: + existing.unlink() + (journal_dir / INDEX_FILE).write_text(_render_index(decisions, index, mode), encoding="utf-8") + except Exception: + pass # rendered files are derived state — never let a render fault break a write diff --git a/plugins/design-patterns/lib/workbench_views.py b/plugins/design-patterns/lib/workbench_views.py index 71ac516..8fff96f 100644 --- a/plugins/design-patterns/lib/workbench_views.py +++ b/plugins/design-patterns/lib/workbench_views.py @@ -20,7 +20,7 @@ def app_html() -> str: