You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
After the blob store ownership migration (milestone #2), the asset pipeline works but stays fragile in three ways:
Non-technical contributors can't push assets. Adding a client logo, team photo, or blog illustration today requires git CLI, knowing the right path under assets/{clients,team,posts}/, running pnpm sync-assets, and editing markdown frontmatter by hand to reference the resulting URL. Anyone outside engineering is blocked.
Naming conventions are implicit. The slug rules (kebab-case identity), category-specific path conventions, and logo variant suffixes (-avatar, -color, -dark, -white) only live in the heads of the team and in the existence of correctly-named files. Nothing prevents a contributor from dropping logo_v2.png in assets/clients/ or Jérôme.JPG in assets/team/. Worse, legacy numeric directories (posts/{1..12}/) coexist with modern slug-named ones (posts/{blog-slug}/) — the convention is not just implicit, it's already inconsistent.
Drift is invisible until someone audits. The one-shot audit in milestone 🔧 update post conversion prompt #2 found 25 orphan files out of 153 (16% drift). With no recurring check, the same drift will rebuild over time as Content evolves, slugs get renamed, and Blog posts get rewritten without cleaning up the matching assets.
Solution
Three deliverables, in order. Each later one depends on the previous.
CONTEXT.md-anchored naming conventions, codified. A small asset-conventions module exports the rules (paths per category, allowed variants per category, slug shape) as data. A generated assets/CONVENTIONS.md documents them for humans. The module becomes the single source of truth referenced by the next two deliverables. In-chantier migration: rename legacy posts/assets/posts/{1..12}/ to posts/assets/posts/{blog-slug}/, rewrite the 12 affected markdown bodies in lockstep, so the conventions module has no legacy exceptions to document.
Orphan-asset audit as a CI guardrail. Promote the milestone 🔧 update post conversion prompt #2 audit script into a permanent check: two deep modules (asset-reference-scanner and asset-orphan-detector) plus a GitHub Action that runs them on every PR touching assets/ or any markdown. The PR is annotated (and optionally blocked) when an asset is added with no consumer, or when a consumer references a missing asset.
Non-tech "add asset" skill. A Claude Code skill (e.g. add-client-logo, add-team-photo, update-post-image) that walks a non-technical contributor through: selecting a category, picking the slug, providing the file, validating the resulting filename against asset-conventions, copying it into place, running pnpm sync-assets, and offering to insert the new URL into the relevant markdown frontmatter via a frontmatter-patcher module.
User Stories
As a Content editor without git experience, I want to add a new client logo by chatting with Claude Code, so that I can ship a Story referencing a new client without asking an engineer.
As a Content editor, I want the skill to refuse a filename that doesn't match the convention, so that I learn the rule by being corrected once rather than producing an orphan.
As a Content editor, I want the skill to offer to insert the uploaded asset's URL into the markdown frontmatter, so that I don't have to copy-paste a long blob URL by hand.
As a Content editor renaming a Team member's slug, I want the skill (or a related one) to update the matching asset filenames in lockstep, so that the photo doesn't become orphaned.
As an engineer reviewing a PR that touches assets/, I want the CI to flag any added file that no markdown references, so that drift is caught at review time, not six months later.
As an engineer reviewing a PR that renames a slug, I want the CI to flag any markdown still referencing the old asset URL, so that broken images don't ship to production.
As an engineer reviewing a PR that deletes an asset, I want the CI to flag any markdown still referencing it, so that broken images don't ship to production.
As a new team member trying to figure out how assets work, I want assets/CONVENTIONS.md to spell out the categories, the variant suffixes, and the slug shape, so that I don't have to reverse-engineer it from filenames.
As an engineer adding a new asset category (say, assets/events/), I want to extend the asset-conventions module in one place, so that the skill, the CI audit, and the docs all pick up the new category without separate updates.
As an engineer running the audit locally, I want a pnpm audit-assets command that produces the same output as the CI, so that I can iterate without pushing.
As an engineer, I want each deep module (asset-conventions, asset-reference-scanner, asset-orphan-detector, frontmatter-patcher) to be unit-testable in isolation with no filesystem or git dependency, so that I can refactor confidently.
As a Content editor uploading a logo with a variant suffix (e.g. -dark), I want the skill to ask which variants I'm providing this round, so that I'm reminded the variant exists without having to read documentation.
As an engineer triaging a recurring asset bug, I want the CONVENTIONS.md to point at the asset-conventions module as its source of truth, so that I always know which one to trust if they disagree.
As a maintainer, I want the CI guardrail to be advisory at first (warn, not block) and tightenable later via a flag, so that we don't break the existing PR flow on day one.
As a Content editor, I want the skill to detect that I'm trying to add a logo for a client whose slug doesn't exist in stories/ yet, so that I'm warned I might be creating an orphan before it lands.
As a maintainer, I want the legacy posts/{1..12}/ numeric directories migrated to posts/{blog-slug}/, so that the asset structure is uniform and the conventions module has no exception to document.
Implementation Decisions
Modules
asset-conventions — Deep, pure module. Source of truth for naming rules. Exports validate(filename, category), pathFor(slug, category, variant), listCategories(), listVariants(category). No filesystem or network. Both asset-conventions data and assets/CONVENTIONS.md derive from the same source; the doc is generated, not hand-maintained.
frontmatter-patcher — Deep, pure module. Reads a markdown file's YAML frontmatter, inserts or updates an asset URL under a given key, returns the new markdown text. No filesystem mutation inside the module — caller writes.
asset-reference-scanner — Deep, pure module. Given a list of markdown files' contents, returns the set of asset paths/URLs they reference. Recognises both repo-relative paths and full blob URLs.
asset-orphan-detector — Deep, pure module. Combines a filesystem listing (passed in) + scanner output + asset-conventions and returns { orphans, missingReferences, conventionViolations }.
Existing posts/scripts/upload-assets.js — Reused as-is by the skill (no rewrite). The skill shells out to pnpm sync-assets after staging the file.
GitHub Action (shallow glue) — Triggers on PRs touching assets/** or **/*.md. Runs the detector, posts a check summary, optionally fails.
Claude skill (shallow glue) — One skill per common entry point (add-client-logo, add-team-photo, update-post-image). Each is a thin orchestration over the four deep modules + sync-assets.
Vocabulary
All output (skill prompts, issue titles, audit reports, CONVENTIONS.md) uses the CONTEXT.md glossary: Content (umbrella), Blog post, Story, Team member, Tool, Job, Slug, Frontmatter. Reserve "asset" for the filesystem artefact itself.
Categories and variants (first cut, refinable in chantier 1)
assets/clients/ — Client logos. Variants: -avatar, -color, -dark, -white. Slug = client identity (matches Story tools references where applicable).
assets/team/ — Team member photos. No variants. Slug must match a team/<slug>.md file. Extension .jpg (canonical) — legacy .jpeg firstname-only files removed in milestone 🔧 update post conversion prompt #2.
assets/posts/<post-slug>/ — Blog post illustrations. Free filename within the post-slug directory. The post-slug must match a blog/<lang>/<slug>.md. Legacy migration scope: 12 numeric directories posts/{1..12}/ (referenced by 12 blog posts) must be renamed to posts/{blog-slug}/ during chantier 1, with the markdown bodies rewritten in lockstep.
(Future) assets/stories/ — Reserved per upload-assets.js help text; not currently populated.
Skill UX shape
Each add-* skill walks:
Ask category (or fix it for category-specific skills like add-client-logo).
Ask slug; validate against existing Content (existing client list, team slugs, blog slugs) — warn if no consumer exists yet.
Ask variant if category supports variants.
Take the file (path or paste).
Compute target path via asset-conventions.pathFor. Show preview. Confirm.
Copy file, run pnpm sync-assets, capture resulting blob URL.
Offer to insert URL into the relevant markdown frontmatter (Story, Team member, Blog post) via frontmatter-patcher. Show diff. Confirm.
CI guardrail rollout
Phase 1: advisory check (warns in PR comment, doesn't fail). Phase 2 (after one month of clean PRs): blocking check, with an override label for legitimate edge cases.
Testing Decisions
A good test here exercises the external behaviour of a deep module against a fixture, without reaching into the filesystem or network. Each module takes plain data in and returns plain data out, which makes them trivially testable.
Modules to test:
asset-conventions — Tests cover: valid filenames per category accepted; invalid filenames rejected with actionable error messages; pathFor produces stable, normalised paths; adding a category in the data triggers no shape regression.
asset-reference-scanner — Tests cover: a markdown referencing a repo-relative asset path produces that path; a markdown referencing a full blob URL produces that URL; a markdown referencing the same asset twice produces it once; ignored markdown sections (code fences, comments) are not scanned.
asset-orphan-detector — Tests cover: orphans surface when filesystem contains a file no scanner output references; missing references surface when scanner references a file the filesystem listing doesn't contain; convention violations surface independent of orphan/missing state; the three sets are disjoint and exhaustive.
frontmatter-patcher — Tests cover: inserting a key into existing frontmatter preserves other keys and YAML formatting; updating an existing key replaces only that key; a file with no frontmatter gets a frontmatter block prepended; idempotent when the URL already matches.
The add-* skills and the GitHub Action are glue and are not unit-tested; they're exercised by running them end-to-end on fixture content during development.
Prior art: no existing test setup in posts (the repo has been content-only). Introducing a minimal test runner (e.g. node --test, no extra dep) alongside the modules is part of chantier 3.
Out of Scope
Rewriting upload-assets.js. The script works; the skill wraps it.
Adding new asset categories (e.g. assets/events/). Listed as future, not delivered here.
Replacing Vercel Blob as the storage backend.
A web UI for non-tech contributors — Claude Code is the UI.
Automated slug rename refactors (rename a Team member's slug and have every reference + asset follow). Adjacent problem, separate PRD. Note: the legacy posts/{N}/ → posts/{blog-slug}/ migration is in scope (one-shot historical cleanup), but a generic slug-rename refactor is not.
Image optimisation, transcoding, or responsive variants generated at upload time.
Further Notes
Order is load-bearing. Chantier 3 (conventions module + doc) is the prerequisite for both 2 and 1; the audit and the skill both consume its rules. Implementing 1 or 2 before 3 means re-encoding the rules twice.
posts/#2 must close first. This milestone assumes the new website-blob store is live, the filesystem is clean, and frontmatter URLs point at the new hostname. Picking up DX work before that introduces a moving target.
Audit baseline: 25 orphans / 153 files = 16% drift at the time of milestone 🔧 update post conversion prompt #2. The CI guardrail's success metric is keeping that ratio at 0% on main going forward.
Legacy posts/{N}/ migration in scope — surfaced during the milestone 🔧 update post conversion prompt #2 audit (PR feat(assets): audit posts/assets/ filesystem against markdown + website slug patterns #40). The 12 numeric directories are all actively referenced by older blog posts; renaming them to the corresponding blog slugs is a one-shot historical cleanup that fits in chantier 1 alongside the conventions module. Deferring it would mean the conventions module ships with an explicit exception and the audit ships with a known-bad set.
The skill is intentionally split per-category (add-client-logo, add-team-photo, update-post-image) rather than one generic add-asset skill, because the prompts and validation differ per category and a focused skill is easier for a non-tech to discover and trust.
Problem Statement
After the blob store ownership migration (milestone #2), the asset pipeline works but stays fragile in three ways:
assets/{clients,team,posts}/, runningpnpm sync-assets, and editing markdown frontmatter by hand to reference the resulting URL. Anyone outside engineering is blocked.-avatar,-color,-dark,-white) only live in the heads of the team and in the existence of correctly-named files. Nothing prevents a contributor from droppinglogo_v2.pnginassets/clients/orJérôme.JPGinassets/team/. Worse, legacy numeric directories (posts/{1..12}/) coexist with modern slug-named ones (posts/{blog-slug}/) — the convention is not just implicit, it's already inconsistent.Solution
Three deliverables, in order. Each later one depends on the previous.
CONTEXT.md-anchored naming conventions, codified. A smallasset-conventionsmodule exports the rules (paths per category, allowed variants per category, slug shape) as data. A generatedassets/CONVENTIONS.mddocuments them for humans. The module becomes the single source of truth referenced by the next two deliverables. In-chantier migration: rename legacyposts/assets/posts/{1..12}/toposts/assets/posts/{blog-slug}/, rewrite the 12 affected markdown bodies in lockstep, so the conventions module has no legacy exceptions to document.Orphan-asset audit as a CI guardrail. Promote the milestone 🔧 update post conversion prompt #2 audit script into a permanent check: two deep modules (
asset-reference-scannerandasset-orphan-detector) plus a GitHub Action that runs them on every PR touchingassets/or any markdown. The PR is annotated (and optionally blocked) when an asset is added with no consumer, or when a consumer references a missing asset.Non-tech "add asset" skill. A Claude Code skill (e.g.
add-client-logo,add-team-photo,update-post-image) that walks a non-technical contributor through: selecting a category, picking the slug, providing the file, validating the resulting filename againstasset-conventions, copying it into place, runningpnpm sync-assets, and offering to insert the new URL into the relevant markdown frontmatter via afrontmatter-patchermodule.User Stories
assets/, I want the CI to flag any added file that no markdown references, so that drift is caught at review time, not six months later.assets/CONVENTIONS.mdto spell out the categories, the variant suffixes, and the slug shape, so that I don't have to reverse-engineer it from filenames.assets/events/), I want to extend theasset-conventionsmodule in one place, so that the skill, the CI audit, and the docs all pick up the new category without separate updates.pnpm audit-assetscommand that produces the same output as the CI, so that I can iterate without pushing.asset-conventions,asset-reference-scanner,asset-orphan-detector,frontmatter-patcher) to be unit-testable in isolation with no filesystem or git dependency, so that I can refactor confidently.-dark), I want the skill to ask which variants I'm providing this round, so that I'm reminded the variant exists without having to read documentation.asset-conventionsmodule as its source of truth, so that I always know which one to trust if they disagree.stories/yet, so that I'm warned I might be creating an orphan before it lands.posts/{1..12}/numeric directories migrated toposts/{blog-slug}/, so that the asset structure is uniform and the conventions module has no exception to document.Implementation Decisions
Modules
asset-conventions— Deep, pure module. Source of truth for naming rules. Exportsvalidate(filename, category),pathFor(slug, category, variant),listCategories(),listVariants(category). No filesystem or network. Bothasset-conventionsdata andassets/CONVENTIONS.mdderive from the same source; the doc is generated, not hand-maintained.frontmatter-patcher— Deep, pure module. Reads a markdown file's YAML frontmatter, inserts or updates an asset URL under a given key, returns the new markdown text. No filesystem mutation inside the module — caller writes.asset-reference-scanner— Deep, pure module. Given a list of markdown files' contents, returns the set of asset paths/URLs they reference. Recognises both repo-relative paths and full blob URLs.asset-orphan-detector— Deep, pure module. Combines a filesystem listing (passed in) + scanner output +asset-conventionsand returns{ orphans, missingReferences, conventionViolations }.posts/scripts/upload-assets.js— Reused as-is by the skill (no rewrite). The skill shells out topnpm sync-assetsafter staging the file.assets/**or**/*.md. Runs the detector, posts a check summary, optionally fails.add-client-logo,add-team-photo,update-post-image). Each is a thin orchestration over the four deep modules +sync-assets.Vocabulary
All output (skill prompts, issue titles, audit reports, CONVENTIONS.md) uses the
CONTEXT.mdglossary: Content (umbrella), Blog post, Story, Team member, Tool, Job, Slug, Frontmatter. Reserve "asset" for the filesystem artefact itself.Categories and variants (first cut, refinable in chantier 1)
assets/clients/— Client logos. Variants:-avatar,-color,-dark,-white. Slug = client identity (matches Storytoolsreferences where applicable).assets/team/— Team member photos. No variants. Slug must match ateam/<slug>.mdfile. Extension.jpg(canonical) — legacy.jpegfirstname-only files removed in milestone 🔧 update post conversion prompt #2.assets/posts/<post-slug>/— Blog post illustrations. Free filename within the post-slug directory. The post-slug must match ablog/<lang>/<slug>.md. Legacy migration scope: 12 numeric directoriesposts/{1..12}/(referenced by 12 blog posts) must be renamed toposts/{blog-slug}/during chantier 1, with the markdown bodies rewritten in lockstep.assets/stories/— Reserved perupload-assets.jshelp text; not currently populated.Skill UX shape
Each
add-*skill walks:add-client-logo).asset-conventions.pathFor. Show preview. Confirm.pnpm sync-assets, capture resulting blob URL.frontmatter-patcher. Show diff. Confirm.CI guardrail rollout
Phase 1: advisory check (warns in PR comment, doesn't fail). Phase 2 (after one month of clean PRs): blocking check, with an override label for legitimate edge cases.
Testing Decisions
A good test here exercises the external behaviour of a deep module against a fixture, without reaching into the filesystem or network. Each module takes plain data in and returns plain data out, which makes them trivially testable.
Modules to test:
asset-conventions— Tests cover: valid filenames per category accepted; invalid filenames rejected with actionable error messages;pathForproduces stable, normalised paths; adding a category in the data triggers no shape regression.asset-reference-scanner— Tests cover: a markdown referencing a repo-relative asset path produces that path; a markdown referencing a full blob URL produces that URL; a markdown referencing the same asset twice produces it once; ignored markdown sections (code fences, comments) are not scanned.asset-orphan-detector— Tests cover: orphans surface when filesystem contains a file no scanner output references; missing references surface when scanner references a file the filesystem listing doesn't contain; convention violations surface independent of orphan/missing state; the three sets are disjoint and exhaustive.frontmatter-patcher— Tests cover: inserting a key into existing frontmatter preserves other keys and YAML formatting; updating an existing key replaces only that key; a file with no frontmatter gets a frontmatter block prepended; idempotent when the URL already matches.The
add-*skills and the GitHub Action are glue and are not unit-tested; they're exercised by running them end-to-end on fixture content during development.Prior art: no existing test setup in
posts(the repo has been content-only). Introducing a minimal test runner (e.g.node --test, no extra dep) alongside the modules is part of chantier 3.Out of Scope
upload-assets.js. The script works; the skill wraps it.assets/events/). Listed as future, not delivered here.posts/{N}/→posts/{blog-slug}/migration is in scope (one-shot historical cleanup), but a generic slug-rename refactor is not.Further Notes
posts/#2must close first. This milestone assumes the newwebsite-blobstore is live, the filesystem is clean, and frontmatter URLs point at the new hostname. Picking up DX work before that introduces a moving target.maingoing forward.posts/{N}/migration in scope — surfaced during the milestone 🔧 update post conversion prompt #2 audit (PR feat(assets): audit posts/assets/ filesystem against markdown + website slug patterns #40). The 12 numeric directories are all actively referenced by older blog posts; renaming them to the corresponding blog slugs is a one-shot historical cleanup that fits in chantier 1 alongside the conventions module. Deferring it would mean the conventions module ships with an explicit exception and the audit ships with a known-bad set.add-client-logo,add-team-photo,update-post-image) rather than one genericadd-assetskill, because the prompts and validation differ per category and a focused skill is easier for a non-tech to discover and trust.