From 9743c35e5ee82746899ea44570408e9ae2172046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivett=20=C3=96rd=C3=B6g?= Date: Wed, 29 Jul 2026 16:50:52 +0200 Subject: [PATCH 1/2] Add Surface Change Attractors pattern Co-Authored-By: Claude Opus 5 --- .../patterns/surface-change-attractors.md | 27 +++++++++++++++++++ documents/relationships.mmd | 3 +++ 2 files changed, 30 insertions(+) create mode 100644 documents/patterns/surface-change-attractors.md diff --git a/documents/patterns/surface-change-attractors.md b/documents/patterns/surface-change-attractors.md new file mode 100644 index 0000000..7fba0ed --- /dev/null +++ b/documents/patterns/surface-change-attractors.md @@ -0,0 +1,27 @@ +--- +authors: [ivett_ordog] +alternative_titles: ["Hotspot Analysis"] +--- + +# Surface Change Attractors + +## Problem +Architectural problems hide as churn. Some files attract every change; some files always change together even though the module structure claims they're independent. Humans normalize this friction. Agents suffer it too: hot files get re-read into context constantly, and edits concentrate exactly where the conflicts and bugs live. None of this is visible by reading the code — the structure looks fine; only the history shows the problem. + +## Pattern +Mine version control for behavioral signals (Adam Tornhill-style analysis): + +- **Hotspots**: change frequency × complexity — files that are both churned and complicated +- **Change coupling**: files that repeatedly change in the same commits despite no visible dependency + +Then hand the findings to the agent as design constraints, not just data: + +- "These five files change together in most commits — propose a redesign that lets them change independently" +- "This file is touched by every feature — split it along its reasons to change" + +Refactor incrementally toward the proposed design. + +Canary in the Code Mine reads the AI's live struggle as the quality signal; Surface Change Attractors finds the same signal in your version history — before the struggle happens. + +## Example +A script over `git log` counts per-file commit frequency and co-change pairs (code-maat and CodeScene do this out of the box — or Offload Deterministic: have the agent write the script). The top attractor is a 900-line "service" touched in 70% of commits, co-changing with a validator and a serializer in two other modules. Given those constraints, the agent proposes moving validation and serialization behind the service's interface. After the refactor, features stop fanning out across three modules. diff --git a/documents/relationships.mmd b/documents/relationships.mmd index b4046f6..38d038a 100644 --- a/documents/relationships.mmd +++ b/documents/relationships.mmd @@ -10,6 +10,9 @@ graph LR patterns/active-partner -->|solves| obstacles/obedient-contractor patterns/canary-in-the-code-mine -->|solves| obstacles/degrades-under-complexity patterns/canary-in-the-code-mine -->|solves| obstacles/context-rot + patterns/surface-change-attractors -->|solves| obstacles/degrades-under-complexity + patterns/surface-change-attractors <-->|similar| patterns/canary-in-the-code-mine + patterns/surface-change-attractors -->|uses| patterns/offload-deterministic patterns/chain-of-small-steps -->|solves| obstacles/degrades-under-complexity patterns/chain-of-small-steps -->|solves| obstacles/limited-focus patterns/check-alignment -->|solves| anti-patterns/silent-misalignment From 409a79d75b50fa68141ed4f5d886973a80b18597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivett=20=C3=96rd=C3=B6g?= Date: Sat, 1 Aug 2026 16:00:52 +0200 Subject: [PATCH 2/2] Link the hotspot-rec skill from Surface Change Attractors Tighten the pattern text, point at the published skill, and show the temporal coupling map it produces. Markdown images are new here, so document images now live in website/public/images/ and every ReactMarkdown call renders them through next/image with the deployment basePath prepended. --- CONTRIBUTE.md | 2 + .../patterns/surface-change-attractors.md | 14 ++++-- website/CLAUDE.md | 2 + website/app/[category]/[slug]/page.tsx | 3 +- website/app/components/markdownComponents.tsx | 22 ++++++++ website/app/pattern-catalog/CatalogView.tsx | 3 +- website/app/talk/PatternModal.tsx | 3 +- .../public/images/example-coupling-map.svg | 11 ++++ .../unit/components/MarkdownImage.test.tsx | 50 +++++++++++++++++++ 9 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 website/app/components/markdownComponents.tsx create mode 100644 website/public/images/example-coupling-map.svg create mode 100644 website/tests/unit/components/MarkdownImage.test.tsx diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index a8b3f8f..ed11049 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -33,6 +33,8 @@ cd website && npm run fetch:videos This updates `website/lib/video-titles.json` (commit it alongside the markdown change). +**Images:** Put the file in `website/public/images/` and reference it as `![alt text](/images/{file})`. The site prepends the deployment base path when rendering. + **Relationships:** Define in `documents/relationships.mmd` using Mermaid graph syntax: ```mermaid patterns/your-pattern -->|solves| obstacles/some-obstacle diff --git a/documents/patterns/surface-change-attractors.md b/documents/patterns/surface-change-attractors.md index 7fba0ed..05160d7 100644 --- a/documents/patterns/surface-change-attractors.md +++ b/documents/patterns/surface-change-attractors.md @@ -6,7 +6,7 @@ alternative_titles: ["Hotspot Analysis"] # Surface Change Attractors ## Problem -Architectural problems hide as churn. Some files attract every change; some files always change together even though the module structure claims they're independent. Humans normalize this friction. Agents suffer it too: hot files get re-read into context constantly, and edits concentrate exactly where the conflicts and bugs live. None of this is visible by reading the code — the structure looks fine; only the history shows the problem. +Architectural problems hide as churn. Some files attract every change; some always change together even though the module structure claims they're independent. Humans normalize this friction. Agents pay for it too: hot files get re-read into context constantly, and edits concentrate exactly where the conflicts and bugs live. The structure looks fine; only the history shows the problem. ## Pattern Mine version control for behavioral signals (Adam Tornhill-style analysis): @@ -14,14 +14,22 @@ Mine version control for behavioral signals (Adam Tornhill-style analysis): - **Hotspots**: change frequency × complexity — files that are both churned and complicated - **Change coupling**: files that repeatedly change in the same commits despite no visible dependency -Then hand the findings to the agent as design constraints, not just data: +Hand the findings to the agent as design constraints: - "These five files change together in most commits — propose a redesign that lets them change independently" - "This file is touched by every feature — split it along its reasons to change" Refactor incrementally toward the proposed design. -Canary in the Code Mine reads the AI's live struggle as the quality signal; Surface Change Attractors finds the same signal in your version history — before the struggle happens. +The metrics nominate candidates, they never decide. Churn says where change lands, the code says why — read every candidate before acting on it. + +Canary in the Code Mine reads the agent's live struggle; Surface Change Attractors finds the same signal in your history, before the struggle. + +An open-source implementation is available at https://github.com/devill/ivetts-skills#hotspot-rec + +![Temporal coupling map: every line joins two files that changed in the same commit, drawn over the package map](/images/example-coupling-map.svg) + +*Circles are files, sized by lines and colored by commits; outlined circles are packages. Orange dashed lines join files that change together across a package boundary — co-change the architecture says should not happen.* ## Example A script over `git log` counts per-file commit frequency and co-change pairs (code-maat and CodeScene do this out of the box — or Offload Deterministic: have the agent write the script). The top attractor is a 900-line "service" touched in 70% of commits, co-changing with a validator and a serializer in two other modules. Given those constraints, the agent proposes moving validation and serialization behind the service's interface. After the refactor, features stop fanning out across three modules. diff --git a/website/CLAUDE.md b/website/CLAUDE.md index 9d08a8c..dbbf9a7 100644 --- a/website/CLAUDE.md +++ b/website/CLAUDE.md @@ -47,6 +47,8 @@ npx playwright test path/to/test.spec.ts **Important**: The first H1 in markdown files is extracted as the page title and removed from the rendered content to maintain semantic HTML (only one H1 per page). +**Images in documents** (agent decision): document images live in `public/images/` and are referenced from markdown as `/images/{file}`. Every `ReactMarkdown` call passes `markdownComponents` from `app/components/markdownComponents.tsx`, which renders images through `next/image` with the deployment `basePath` prepended — a bare `` would 404 on GitHub Pages. Add the override to any new `ReactMarkdown` call site. + ### Category Configuration System **Centralized in** `app/lib/category-config.ts`: diff --git a/website/app/[category]/[slug]/page.tsx b/website/app/[category]/[slug]/page.tsx index ae345fd..702da51 100644 --- a/website/app/[category]/[slug]/page.tsx +++ b/website/app/[category]/[slug]/page.tsx @@ -9,6 +9,7 @@ import { PatternCategory } from "@/lib/types"; import Authors from "@/app/components/Authors"; import RelatedLinks from "@/app/components/RelatedLinks"; import VideoThumbnail from "@/app/components/VideoThumbnail"; +import { markdownComponents } from "@/app/components/markdownComponents"; import styles from "../../pattern-detail.module.css"; interface PatternPageProps { @@ -155,7 +156,7 @@ export default async function PatternPage({ params }: PatternPageProps) {
- + {pattern.content}
diff --git a/website/app/components/markdownComponents.tsx b/website/app/components/markdownComponents.tsx new file mode 100644 index 0000000..d3eb8c7 --- /dev/null +++ b/website/app/components/markdownComponents.tsx @@ -0,0 +1,22 @@ +import type { ComponentPropsWithoutRef } from "react"; +import Image from "next/image"; +import { basePath } from "@/lib/config"; + +const isExternal = (src: string) => /^https?:\/\//.test(src); + +export function MarkdownImage({ src, alt }: ComponentPropsWithoutRef<"img">) { + if (typeof src !== "string") return null; + + return ( + {alt + ); +} + +export const markdownComponents = { img: MarkdownImage }; diff --git a/website/app/pattern-catalog/CatalogView.tsx b/website/app/pattern-catalog/CatalogView.tsx index e5f6b6a..2c30d27 100644 --- a/website/app/pattern-catalog/CatalogView.tsx +++ b/website/app/pattern-catalog/CatalogView.tsx @@ -11,6 +11,7 @@ import { COMPLETE_CATALOG_TEST_IDS } from "./test-ids"; import { getCategoryConfig } from "@/app/lib/category-config"; import SearchBar from "@/app/components/SearchBar"; import VideoThumbnail from "@/app/components/VideoThumbnail"; +import { markdownComponents } from "@/app/components/markdownComponents"; import { PatternContent } from "@/lib/types"; interface CatalogViewProps { @@ -450,7 +451,7 @@ export default function CatalogView({ groups, title }: CatalogViewProps) { )}
- + {selected.item.content}
diff --git a/website/app/talk/PatternModal.tsx b/website/app/talk/PatternModal.tsx index 620797a..d6e36d8 100644 --- a/website/app/talk/PatternModal.tsx +++ b/website/app/talk/PatternModal.tsx @@ -1,5 +1,6 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { markdownComponents } from "@/app/components/markdownComponents"; import styles from "./PatternModal.module.css"; import detailStyles from "../pattern-detail.module.css"; @@ -47,7 +48,7 @@ export default function PatternModal({ pattern, onClose }: PatternModalProps) {
- + {pattern.content}
diff --git a/website/public/images/example-coupling-map.svg b/website/public/images/example-coupling-map.svg new file mode 100644 index 0000000..1aac18d --- /dev/null +++ b/website/public/images/example-coupling-map.svg @@ -0,0 +1,11 @@ + diff --git a/website/tests/unit/components/MarkdownImage.test.tsx b/website/tests/unit/components/MarkdownImage.test.tsx new file mode 100644 index 0000000..381f370 --- /dev/null +++ b/website/tests/unit/components/MarkdownImage.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react' +import { MarkdownImage } from '@/app/components/markdownComponents' + +let mockBasePath = '' + +jest.mock('@/lib/config', () => ({ + get basePath() { + return mockBasePath + }, +})) + +describe('MarkdownImage', () => { + afterEach(() => { + mockBasePath = '' + }) + + it('prefixes a document image with the deployment base path', () => { + mockBasePath = '/augmented-coding-patterns' + + render() + + expect(screen.getByAltText('An example figure')).toHaveAttribute( + 'src', + '/augmented-coding-patterns/images/example.svg', + ) + }) + + it('leaves the path alone for a root deployment', () => { + render() + + expect(screen.getByAltText('An example figure')).toHaveAttribute('src', '/images/example.svg') + }) + + it('leaves an external image untouched', () => { + mockBasePath = '/augmented-coding-patterns' + + render() + + expect(screen.getByAltText('An external figure')).toHaveAttribute( + 'src', + 'https://example.com/figure.svg', + ) + }) + + it('renders nothing without a source', () => { + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) +})