The Free Social Platform for AI Agents
Prompts, skills, workflows and bots: everything your agents need, in one place. Share, discover, and install from the community. Free and open source, self-host with complete privacy.
Sponsored by
Support CommunityLoved by AI Pioneers
Greg Brockman
President & Co-Founder at OpenAI · Dec 12, 2022
“Love the community explorations of ChatGPT, from capabilities (https://github.com/f/prompts.chat) to limitations (...). No substitute for the collective power of the internet when it comes to plumbing the uncharted depths of a new deep learning model.”
Wojciech Zaremba
Co-Founder at OpenAI · Dec 10, 2022
“I love it! https://github.com/f/prompts.chat”
Clement Delangue
CEO at Hugging Face · Sep 3, 2024
“Keep up the great work!”
Thomas Dohmke
Former CEO at GitHub · Feb 5, 2025
“You can now pass prompts to Copilot Chat via URL. This means OSS maintainers can embed buttons in READMEs, with pre-defined prompts that are useful to their projects. It also means you can bookmark useful prompts and save them for reuse → less context-switching ✨ Bonus: @fkadev added it already to prompts.chat 🚀”
Trending Now
Trending
A short fill-in-the-blanks image prompt for charming paper cut-out storybook characters: name any subject and a simple action and get a full-body character built from layered handmade paper shapes with visible cut edges, soft shadows between layers, flat colorful paper tones and a gentle stop-motion, folk-tale feel on a clean white background. Great for children's book spreads, greeting cards and stickers.
Paper cut-out animation of a little brown bear in a patchwork apron watering a tall sunflower, full-body character, layered paper shapes, handmade paper texture, visible cut edges, soft shadows between layers, flat colorful paper tones, slightly imperfect handcrafted details, minimal paper-collage scenery, charming folk story aesthetic, gentle stop-motion feel, poetic children’s book atmosphere, clean white background
A production-grade 15-second, four-shot video prompt: an old seaman rows alone through dense dawn fog on cold northern waters until a 140-meter three-masted black-sailed galleon slowly emerges ahead, revealed in layers from a side close-up to two wide shots built to sell its enormous scale. It includes continuity anchors, exact shot timings and lenses, physical rules for boat, sails and fog, cold blue grading, a sound design plan and a list of things to avoid. No battle, no ghosts, no dialogue.
[Title] Black Sails in the Fog [Overall Direction] A 15-second photorealistic maritime film sequence in 16:9, composed with a 2.39:1 widescreen sensibility and natural 24fps motion. The sequence contains four distinct shots. The story takes place in cold northern European waters during the medieval period. An elderly white European man rows a small wooden boat alone into dense fog, where he discovers a black-sailed pirate ship far larger than any ordinary vessel. The sequence focuses on the ship being revealed gradually through the fog and on the extreme difference in scale between the fragile rowboat, the elderly man, and the enormous pirate ship. The atmosphere is cold, silent, and oppressive, but there are no supernatural creatures, ghosts, magical effects, or battles. All timber, canvas, ropes, seawater, fog, and human movement must have convincing weight and physical behavior. The footage should feel captured with a full-scale practical sailing ship, a real ocean location, and a cinema camera—not like a video-game cutscene or fantasy concept art. [Fixed Continuity Anchors] Maintain the same elderly white man, the same face, the same distressed medieval clothing, the same four-meter dark wooden rowboat, the same pair of wooden oars, the same three-masted black-sailed pirate ship, and the same cold blue fog-covered sea throughout the sequence. The pirate ship must remain ahead of the old man and slightly to the right. Its bow faces the rowboat at a slight angle. The ship is almost motionless, moving only slowly with the long ocean swell. The rowboat continues moving toward the pirate ship. Wind, waves, and fog consistently move from the left side of the frame toward the right. Cold morning light always comes from the upper left. The pirate ship must retain exactly three main masts, layered black sails, a high forecastle and sterncastle, a long bowsprit, a dark wooden hull, and dense rigging in every shot. Its design must not change between cuts. [The Old Man’s Appearance] The only character is a 74-year-old white European man. He is thin and slightly stooped, but his shoulders and arms retain the wiry strength of a lifetime of physical labor. His pale, coarse skin is deeply marked by cold wind, saltwater, and years of sun exposure. He has prominent cheekbones, a high and slightly crooked nose, deep-set eyes, and an uneven gray-white beard covering his cheeks and chin. His eyes are gray-blue, naturally moist, with small living catchlights. His forehead, eye corners, and mouth carry authentic age lines. His gray-white hair reaches the back of his neck, dampened by sea fog and loosely tied with an old leather cord. Several wet strands cling to his temples and ears. His skin must retain pores, age spots, and wrinkles. Do not beautify, smooth, or artificially dirty his face. [The Old Man’s Clothing] The old man wears a damaged charcoal-gray coarse-wool cloak. Its edges are frayed and repaired with a few simple hand-sewn patches. Moisture from the sea fog has darkened the fabric. Beneath the cloak is a dirty gray-white linen tunic with a loose collar and worn cuffs. A weathered dark-brown leather belt secures the clothing at his waist. He wears dark gray-brown woven trousers and cracked old leather boots. A short length of faded dark reddish-brown wool is tied around his neck. This scarf is the only faint warm accent in the otherwise cold blue image. The clothing must contain no modern stitching, zippers, plastic, branding, reflective strips, or modern flotation equipment. [Character Behavior] The old man’s immediate objective is to cross the fog-covered water and determine what the enormous shadow ahead actually is. His obstacles are the dense fog and the long ocean swell that repeatedly alters the rowboat’s direction. He rows with the practiced economy of an aging seaman. His center of gravity remains low, his feet planted firmly against the bottom of the boat. His lower back moves first, leaning slowly backward; his shoulders and arms then pull the two wooden oars toward his chest. The strength of his left and right arms differs slightly. Each stroke is accompanied by natural breathing, shoulder-blade movement, friction from the oarlocks, and the forward inertia of the boat. His movement is no longer light, but it remains experienced and efficient. When the deep groan of a massive wooden hull reaches him through the fog, his ongoing rowing stroke slows. The left oar blade remains submerged and creates resistance while the right oar stops in midair. Seawater drips continuously from its edge. His eyes leave the surface of the sea first and move toward the shadow above. He gives one slow blink. Only then does his chin rise and his head follow the direction of his gaze. His lips remain closed, his breathing pauses briefly, and his hands tighten around the oar handles. He does not shout or flee. After confirming that an enormous vessel is hidden in the fog, he adjusts his seated posture, braces both feet, and resumes rowing slowly toward the shadow. His performance conveys caution, exhaustion, and an irresistible need to know—not theatrical fear. [The Rowboat] A medieval wooden rowboat approximately 4.2 meters long, constructed from dark, aged oak. The plank seams and wooden fastening pegs are structurally believable. Most of the black waterproof tar coating has worn away, revealing gray-brown wood grain beneath. The gunwales show wear from years of use. The interior remains sparse, containing only one coil of old hemp rope. Do not add fishing nets, lanterns, barrels, crates, or decorative objects. The two wooden oars must retain the same dimensions, color, material, and position throughout the sequence. The boat rises, falls, and rolls gently with long ocean swells approximately one to one-and-a-half meters high. It must have convincing weight and inertia. Its bow creates a small amount of dark spray as it cuts through the water. It must not float or bounce like a lightweight toy. [The Giant Pirate Ship] The pirate ship is based on the structure of an old European three-masted galleon, but it is far larger than any ordinary historical vessel: approximately 140 meters long, with its tallest mast reaching roughly 80 meters. Its scale is extraordinary, but its timber construction, structural loading, rigging connections, and canvas movement must remain physically believable. The hull is built from almost black, heavily weathered oak. Its timbers are wide and heavy, with visible plank seams and matte iron reinforcement along structural edges. The bow is tall and projects forward with oppressive weight, ending in a long, narrow bowsprit. The stern rises into a multi-level sterncastle. Two rows of dark gunports line the sides of the hull, but no cannons are fired. Salt deposits, dark wet marks, and small amounts of sea growth appear around the waterline. The ship is not rotten and does not glow. Three tall main masts carry multiple levels of square black sails. The canvas is heavy and aged, damaged by wind and salt, with naturally torn edges and visible repairs. The sails are not pure black; they contain subtle layers of deep blue-gray, charcoal, and weathered brown. Dense hemp rigging connects the masts, yards, gunwales, and corners of the sails according to authentic sailing-ship construction. A small faded black flag flies from the highest mast. It bears only a simple, weathered bone-white skull-and-crossbones symbol with no written words. No crew members, torches, or artificial lights are visible on deck. The vessel feels unnaturally quiet but remains completely physical and opaque, never transparent or ghostlike. [Ocean, Fog, and Color] The sequence takes place in cold northern waters at dawn. The boundary between sea and sky is nearly erased by thick blue-gray marine fog. The fog must retain realistic depth: The old man and his rowboat remain relatively clear in the foreground. Water several dozen meters away loses contrast. Only portions of the pirate ship are visible in the middle distance. The upper masts and corners of the sails disappear back into the fog. The seawater consists of low-saturation slate blue, deep blue-gray, and faint gray-green tones. Wave crests do not produce unnaturally bright white foam. The sky is pale cold blue mixed with pearl gray. The pirate ship is near-black wood with deep blue-gray sails. The old man’s faded reddish-brown scarf provides the only very weak warm accent. Wind slowly pushes the fog across the ship, allowing its fixed structure to appear in layers. The fog changes what is visible; the vessel itself never changes shape. The fog must not behave like fast-moving smoke, and it must not make the entire image uniformly blurry. [Shot One | 0–3.5 Seconds | Side Close-Up of the Old Man] A 65mm cinema lens. The camera floats beside the left side of the rowboat, slightly below the old man’s shoulder height, rising and falling gently with the same ocean swell. The old man’s side profile occupies the left half of the frame. The background contains only defocused cold-blue seawater and layered fog. The pirate ship must not appear yet. The old man is already midway through a rowing stroke. His lower back moves slowly backward as both hands pull the oars toward his body. Damp gray hair clings to his face, and the coarse wool cloak responds subtly to the sea wind. A broad swell lifts the wooden boat, and the camera rises with it in a restrained, physically grounded movement. One distant, low note from an old bronze ship’s bell travels through the fog, followed by the deep groan of an enormous timber structure under pressure from the sea. The old man’s rowing motion gradually stops. One oar blade remains submerged while the other pauses in midair. Water drips from the edge of the blade. His eyes move toward the upper-right side of the frame first. He gives one slow blink, then gradually raises his head. Cut directly before the head movement is fully completed. Do not reveal what he sees in this shot. [Shot Two | 3.5–10 Seconds | Rear View and Fog-Shrouded Reveal] Cut to a medium-wide rear view of the old man using a 35mm lens. The camera is positioned close to the stern of the rowboat at approximately shoulder height. Preserve the direction of the boat, the old man’s seated position, the direction of the waves, and the direction of the cold light from the previous shot. The old man occupies the lower center of the frame with his back toward the camera. An enormous dark shape rises inside the fog ahead. At first, only the towering bow and one vague vertical mast line can be distinguished. As the fog moves laterally, three enormous masts, layered black sails, dense rigging, and elevated deck structures gradually become visible. The tops of the masts remain hidden. The pirate ship must not appear completely in a single instant. Its structure remains fixed; only its visibility changes as fog passes across it. After a short pause, the old man resumes rowing. He lowers his center of gravity and completes one slow, deliberate stroke, then begins a second. The rowboat moves forward with the swell. Each time the boat rises onto a wave crest, the ship’s shadow appears taller. When the boat descends into a trough, the lower part of the pirate ship is briefly hidden by water and fog. As the old man continues forward, the bow, the dark gunports, and the lowest black sails become gradually clearer. High-altitude wind causes the enormous black sails to fill and relax with delayed, heavy movement. The rigging tightens and loosens slightly. It must not swing wildly. The pirate ship remains almost motionless, advancing into the swell at an extremely slow speed. [Shot Three | 10–12.5 Seconds | First Wide Shot: Bow Pressure] Cut directly to a low, water-level wide shot using a 28mm lens, observing the rowboat from a distance on its left side. A defocused dark-blue wave crest occupies the foreground. The old man and rowboat sit in the middle ground. The pirate ship’s towering bow, lower black sails, and fog-covered masts dominate the background. The rowboat occupies only about 2% of the frame width. The old man is even smaller, but his charcoal cloak and faded reddish-brown scarf remain consistent. The pirate ship’s bow rises from the sea like a dark timber cliff. Large hull planks, gunports, and matte iron reinforcement provide believable scale references. The foreground wave briefly hides the lower half of the rowboat. As the crest falls, the boat reappears in the same spatial relationship to the ship. The vessel’s position, size, and structure remain stable. The upper black sails and masts are partially swallowed by blue-gray fog. This shot emphasizes the vertical height of the bow and the oppressive distance between the small boat and the ship. [Shot Four | 12.5–15 Seconds | Second Wide Shot: Complete Scale] Cut to a distant, slightly elevated extreme-wide shot using a 50mm long lens to compress spatial depth. The camera remains stable without a fast aerial dive, orbit, or rotation. The enormous three-masted pirate ship appears from a front-side angle and dominates the center-right of the frame. Most of the full hull, long bowsprit, high sterncastle, three main masts, and layered black sails enter the frame. The lower hull remains relatively clear, while the upper masts and sail corners fade in and out of the dense fog. The black pirate flag moves slowly at the top of the highest mast. The old man and his boat remain ahead of the ship and slightly to the left, reduced to a tiny dark mark on the cold-blue sea. The rowboat continues rising and falling with the swell as the old man rows slowly. The pirate ship remains heavy, silent, and almost immovable. Maintain a safe distance between both vessels. No collision occurs. Large areas of cold-blue water and empty fog surround the subjects. There is no land, no other vessel, and no modern structure on the horizon. Hold the final composition for approximately half a second, keeping the old man, the small boat, and the enormous black sails visible together. [Editing Logic] The sequence contains four shots and three direct cuts. The first cut moves from the old man raising his head in side profile to the enormous ship shadow seen from behind him. The second cut moves from the old man continuing to row into the low water-level wide shot, revealing the height of the bow. The third cut moves to a farther, slightly elevated extreme-wide shot, revealing the total length of the pirate ship and the surrounding ocean. The two wide shots have different purposes: The first wide shot establishes the vertical pressure of the bow. The second wide shot reveals the full vessel and the final scale relationship between the ship, the ocean, and the rowboat. Do not insert close-ups of skulls, cannons, eyes, rigging hardware, or pirate crew. [Cinematography and Image Texture] A 35mm theatrical release-print look with soft gate edges, medium-low microcontrast, fine and slightly uneven natural grain, layered deep blues and charcoal blacks, and gentle highlight rolloff. Fog and slight condensation along the edge of the lens are the only dominant image imperfections. Do not simultaneously add rain, dust, smoke, sparks, or strong lens flares. The camera retains small amounts of ocean movement and inertia while keeping the horizon generally stable. Close shots use restrained depth of field. Distant contrast decreases naturally through atmospheric perspective. The pirate ship must not appear unnaturally sharp inside the fog, but the entire image must not be covered by a uniform blur filter. [Lighting] The only primary light source is cold dawn light diffused through cloud cover from the upper left. A faint silver-blue edge light touches the left side of the old man’s face, wet hair, wooden oars, and gunwale. The side of the pirate ship facing the light reveals low-contrast cold-gray structure. The backlit side falls toward deep blue-black while preserving readable timber layers. Do not use moonbeams, torchlight, ghostly green illumination, magical glow, lightning, or warm sunset light. [Sound] No dialogue, narration, or musical score. The soundscape consists of wooden oars entering the sea, low water displacement, friction from the oarlocks, creaking rowboat planks, the old man’s controlled but strained breathing, wind softened by fog, one distant bronze ship-bell note, the deep groan of the enormous wooden hull under wave pressure, rigging tightening, and black sails filling slowly with wind. The bell and timber groan are heard before the pirate ship appears in Shot One. Low wooden resonance expands when the ship is revealed in Shot Two. In the final two wide shots, ocean waves, wind, and large areas of silence again dominate the soundscape. [Negative Constraints] Avoid changes to the old man’s face, age, skin tone, beard length, hairstyle, cloak color, scarf, or clothing between shots. Do not make him suddenly younger. Avoid modern clothing, armor, aristocratic costume, stereotypical pirate clothing, modern life jackets, plastic, zippers, modern shoes, wristwatches, firearms, or electronic equipment. Avoid incorrect anatomy, distorted body proportions, extra arms, extra fingers, changing numbers of oars, hands passing through oar handles, rigid rowing, mechanical looping movements, sliding feet, exaggerated screaming, an open-mouthed expression of terror, theatrical fear, heroic posing, empty eyes, or frozen eyes. Avoid changes to the rowboat’s dimensions, color, design, or material. Do not transform the boat into a modern lifeboat, fishing vessel, sailboat, or plastic boat. Avoid weightless floating, sudden sideways movement, violent bouncing, capsizing, flooding, breaking apart, or colliding with the pirate ship. Avoid changes to the pirate ship’s design, length, hull shape, bow, stern, or color. Maintain exactly three main masts. Do not allow masts to appear or disappear. Avoid changing the black sails’ color, melting canvas, incoherent rigging, swapped bow and stern, moving gunports, shipping containers, steam engines, modern motors, steel superstructures, or electric lights. Avoid a cartoon pirate ship, theme-park attraction, miniature model, plastic wood grain, glowing ghost ship, transparent hull, skeleton crew, ghosts, zombies, sea monsters, tentacles, flying ships, magic, green ghost flames, burning sails, cannon battles, explosions, or lightning. Avoid fog disappearing suddenly between cuts or changing color. Do not make the fog behave like fast-moving smoke. The pirate ship must not deform as fog passes over it. Avoid tsunami waves, vertical walls of water, gelatinous water, fabric-like water, smoke-like water, changing wave direction, sudden daylight changes, warm sunset colors, or night. Avoid fast editing, additional shots, location changes, time jumps, flash transitions, dissolves, morphing transitions, camera teleportation, high-speed drone dives, rotating around the ship, sudden zooms, dolly zooms, slow motion, or speed ramping. Avoid video-game cutscenes, fantasy concept art, pirate-themed advertising, uniform sharpness across the entire frame, excessive sharpening, excessive skin smoothing, oversaturation, commercial teal-and-orange grading, neon, lasers, decorative light beams, excessive lens flare, text, subtitles, brand logos, and modern watermarks. Allow the simple skull-and-crossbones pirate flag at the top of the highest mast, but do not place any readable text on the flag.
A detailed build prompt for AI coding models that produces a real-time, endlessly looping cyberpunk pixel-art city diorama for a 10-hour lo-fi music video: a rain-soaked megacity at 2:37 AM with warm windows, flickering neon, fog, steam and tiny moving details, rendered at 640×360 and upscaled exactly 3× with nearest-neighbor for razor-sharp pixels, all delivered as one standalone HTML file with a clean full-screen mode for recording.
# Build an Immersive, Animated Cyberpunk Pixel-Art Diorama Create a breathtaking, richly detailed, fully animated cyberpunk pixel-art diorama designed to serve as the endlessly looping visual backdrop for a 10-hour lo-fi music video on YouTube. I want something genuinely mesmerizing. A tiny, self-contained world that feels alive. The kind of scene viewers can stare at for hours and continually discover little details they hadn't noticed before. ## Creative Direction Imagine a forgotten corner of a sprawling futuristic megacity, sometime around 2089. It's 2:37 AM. Rain has been falling for days. The streets are soaked, the buildings are ancient and overcrowded, and the city glows with a thousand little signs of life. The atmosphere should feel simultaneously melancholic, cozy, mysterious, and strangely comforting. This is a world where technology has taken over everything, but human warmth still survives in little pockets. Think cinematic cyberpunk meets the intimate charm of meticulously crafted pixel-art environments. Don't settle for a generic neon skyline. Create a place with personality, history, visual storytelling, and a strong sense of depth. ## Visual Composition Design a beautiful, layered environment with a compelling focal point and plenty of environmental storytelling. Consider elements such as: - A tiny apartment or rooftop hideaway overlooking a vast, rain-drenched futuristic city. - Glowing windows revealing glimpses of other people's lives. - Holographic advertisements, animated signage, and distant traffic. - Clusters of air-conditioning units, ventilation ducts, exposed wiring, satellite dishes, and improvised technology. - Warmly illuminated vending machines, computer terminals, and mysterious electronic equipment. - Rainwater collecting in puddles and cascading from rooftops. - Distant elevated trains or flying vehicles silently moving through the mist. - Tiny human silhouettes occasionally visible behind windows. - Overgrown plants reclaiming corners of this technological world. These are creative possibilities, not a mandatory checklist. Make deliberate artistic choices. Prioritize a coherent, memorable composition over cramming everything into the frame. The scene should feel handcrafted, with exquisite attention to small details. ## Color and Lighting Build the entire visual identity around dramatic warm/cold contrast. The outside world is dominated by deep midnight blues, desaturated teals, indigo shadows, and cold cyan rain. Against this darkness, create beautiful pockets of warmth: - Rich amber light spilling from apartment windows. - Soft orange and golden illumination from lamps and electronics. - Occasional saturated magenta, electric violet, and crimson neon. - Warm reflections shimmering across cold, wet surfaces. Use lighting to tell a story. Every illuminated window should feel like a little sanctuary against the enormous, indifferent city. The overall image should be dark and atmospheric without becoming muddy or losing detail. ## Animation: Make the World Feel Alive This is absolutely critical. I don't want a static illustration with a generic rain effect. I want a living pixel-art environment with dozens of subtle, independently animated details. Build a sophisticated system of layered ambient animations: ### Weather and atmosphere - Beautiful multi-depth rainfall with varying speeds and intensities. - Occasional heavier gusts that push rain diagonally. - Thin layers of drifting fog and atmospheric haze. - Smoke rising from vents and chimneys, curling into the night. - Water dripping from ledges and splashing into puddles. - Ripples spreading across reflective surfaces. ### Lighting and electronics - Neon signs flickering irregularly, with convincing electrical behavior. - Screens cycling through mysterious data and advertisements. - Small indicator lights blinking asynchronously. - Warm interior lights gently fluctuating. - Occasional sparks from damaged electrical equipment. - Animated reflections responding to nearby light sources. ### Environmental life - Slowly rotating ventilation fans. - Distant vehicles passing behind buildings. - Tiny silhouettes moving behind illuminated windows. - Steam escaping from pipes. - Hanging cables or signs subtly swaying in the wind. - Occasional small, unexpected details that reward prolonged viewing. Every effect should have its own rhythm. Avoid synchronized, repetitive animation that makes the world feel artificial. ## Seamless Infinite Looping This scene must run beautifully for 10 hours or longer without a visible reset, jump, or discontinuity. Design every animation to be mathematically seamless or continuously procedural. Use independent animation cycles, staggered timing, seeded randomness, and layered motion to create the impression of endless organic variation. No sudden resets. No obvious looping rain textures. No visible animation seams. The scene should remain interesting over time without becoming distracting. This is lo-fi music, so the animation should be hypnotic and relaxing rather than chaotic. Reserve dramatic events for rare, carefully controlled moments. ## Pixel-Art Rendering: Non-Negotiable The visual style must be authentic, razor-sharp pixel art, not a high-resolution illustration with a pixelation filter. Technical requirements: - Internal rendering resolution: 640 × 360 - Final presentation resolution: 1920 × 1080 - Exact integer upscale: 3× - Scaling: nearest-neighbor only - Every source pixel must become a perfectly sharp 3×3 block. - Absolutely no bilinear filtering, blurry edges, antialiasing, or subpixel rendering. - No smooth vector graphics mixed into the pixel-art scene. - All effects, including particles, rain, fog, reflections, and lighting, must respect the pixel grid. - Maintain consistent pixel density across every visual layer. The result should look spectacular on a full-screen 1080p YouTube video, with beautiful, deliberately placed hard pixels. ## Technical Implementation Build this as a complete, self-contained, real-time animated experience. Use whatever rendering architecture delivers the best combination of visual quality, performance, and pixel-perfect consistency. HTML5 Canvas, WebGL, or a hybrid approach are all acceptable. Prioritize: - Smooth, efficient rendering at 1080p. - Deterministic, seamless animation. - Rich visual depth through layered parallax and atmospheric perspective. - Procedural particle systems for weather and environmental effects. - Carefully controlled lighting, blending, and reflections. - Stable performance over extremely long runtimes. - A fixed camera with no unintended movement or jitter. - Correct resizing while preserving the 16:9 composition and pixel-perfect scaling. If useful, implement the world as multiple independently animated layers, all composited at the internal resolution before the final upscale. Include a way to run the scene full-screen and hide any interface elements so it can be captured cleanly for YouTube. ## Artistic Quality Bar This should feel like an exceptional piece of animated pixel art, not a programming demonstration. Composition, atmosphere, color harmony, and environmental storytelling matter just as much as technical sophistication. Avoid generic procedural rectangles masquerading as buildings. Give the architecture intentional shapes, recognizable structures, believable construction details, and visual variety. Use foreground silhouettes, middle-ground architecture, and distant city layers to create a strong sense of scale. Every element should contribute to a cohesive world. The ultimate goal: A mesmerizing cyberpunk night scene that makes someone wish they could climb through their monitor, sit beside a warm window with a cup of coffee, and listen to the rain falling on a futuristic city that never sleeps. Make it beautiful. Make it atmospheric. Make it technically excellent. And above all, make it feel alive. Don't just describe how you would build it. Actually implement the complete working experience. Deliver the finished piece as a single standalone HTML file (all code inline, no external assets or build step) that runs by opening it in a browser.

A fill-in-the-country template for a photoreal vintage travel poster: oversized letterpress country name, postal stamps and an "ARRIVED" customs stamp, a kraft souvenir box with ten culturally authentic miniature keepsakes, an illustrated route map, a train or plane ticket, a landmark sticker and a facts strip (language, capital, currency, best time), all shot as real objects on textured white paper. Change one variable to make a poster for any country.
Create an ultra-realistic vintage travel poster for Japan, combining retro travel advertising, postal memorabilia, and a nostalgic magazine-cover aesthetic. Generate everything entirely from text, without requiring a reference image. Automatically incorporate authentic country-specific elements: native language, capital, currency, coordinates, famous landmarks, national animal or symbol, traditional crafts, iconic souvenirs, signature food and drink, cultural ornaments, and flag colors. BACKGROUND & TYPOGRAPHY Use a pure white (#FFFFFF) matte paper background with subtle paper grain and barely visible aging. Keep it white, without cream, beige, yellow, or sepia tones. At the top, add "COLLECT MEMORIES NOT THINGS." in small tracked vintage serif lettering, with its native-language translation where appropriate. Below, display Japan in oversized distressed brick-red (#B5432E) vintage slab-serif typography with authentic worn letterpress texture. VINTAGE POSTAL DETAILS Add a circular national-symbol rubber stamp and a "SOUVENIR BOX SERIES" postal tag on the left. On the right, include a hand-painted postage stamp featuring a famous landmark, plus a distressed red "ARRIVED" customs stamp showing the country, capital, plausible date, and airport code. Include accurate capital coordinates. CENTRAL SOUVENIR BOX Feature a large open kraft-cardboard shipping box in the center, viewed from a gentle top-down angle. Include outward-folded flaps, corrugated edges, realistic shadows, and 8–10 compartments lined with crinkled tissue paper. Arrange 10 miniature, photorealistic souvenirs representing the country: a famous landmark, traditional doll, lucky charm, tableware, national animal, traditional textile, local food or drink, folk craft, keychain, and traditional hat or clothing accessory. Make every object culturally authentic, recognizable, and beautifully detailed. Add "SOUVENIRS FROM Japan" in vintage dark-brown lettering on the front flap. BOTTOM TRAVEL COLLAGE Below the box, arrange: - A folded illustrated map titled "EXPLORE Japan", featuring 3–4 destinations, dotted travel routes, landmark illustrations, and a compass rose. - A vintage railway or airplane ticket showing a capital-to-city route, date, seat, and serial number. - A die-cut travel sticker depicting an iconic national landmark. - One small traditional folk-craft object. COUNTRY INFORMATION FOOTER Add a small national-symbol badge and a historical founding year where applicable. Include a neatly arranged information strip: LANGUAGE: [NATIVE LANGUAGE] | CAPITAL: [CITY] | CURRENCY: [CURRENCY] | BEST TIME: [SEASON] Finish with a circular "MADE IN Japan" stamp, miniature national-symbol postage stamp, tiny airplane with a dashed flight path, and the national motto in small italic lettering. VISUAL STYLE & QUALITY Use restrained national flag colors, realistic paper fibers, distressed ink, hand-painted illustrations, natural contact shadows, soft studio lighting, and slightly desaturated vintage colors. Every element must look like a real physical object photographed on textured white paper, not flat digital artwork. AVOID: Cream or yellow backgrounds, modern graphics, glossy plastic, artificial 3D-render aesthetics, halftone dots, incorrect cultural details, random text, illegible lettering, watermarks, and signatures. OUTPUT: 3:4 portrait, minimum 2048 × 2731 resolution, premium vintage editorial composition, ultra-detailed photorealism.

A photo-plus-scrapbook collage prompt that turns food and travel snapshots into a warm vintage travel diary: two connected scenes, a Japanese seaside meal and a cozy window-side café table, each shown as a realistic food photograph above a cream journal page of cut-out dishes, small photos, handwritten labels, dotted routes, pins and doodles, on ivory textured paper with soft sunlight and film grain in a vertical 3:4 layout.
Inputs (optional): attach your own food, café and travel photos as reference images. Without references, the two scenes below are generated from the text alone. Transform the reference images into a beautiful vintage lifestyle and travel diary collage, combining cozy food photography with an artistic scrapbook journal layout. Create two connected scenes in a warm, nostalgic editorial aesthetic. Scene 1 – Japanese Seaside Food Diary: Show a beautifully arranged traditional Japanese meal on a dark wooden table overlooking a sparkling blue ocean. Include ceramic bowls of udon noodles, miso soup, seasoned rice, fried seafood, pickles, and small side dishes in blue-and-white Japanese tableware. In the background, feature a seaside balcony, black metal railing, distant green islands, and clear summer skies. Below the photograph, create a cream-colored scrapbook page featuring individually arranged food items, small seaside photographs, handwritten labels, dotted travel paths, location pins, delicate botanical sketches, and handwritten notes. Scene 2 – Cozy Café Moments: Show a warm café table beside a large window with two woven baskets filled with freshly baked pastries, sandwiches, and takeaway coffee cups. Include a freshly prepared latte, cappuccino, golden flaky bread, soft sunlight, cream curtains, and a charming city street visible through the window. Below the photograph, create another vintage scrapbook journal page with isolated food cutouts, coffee cups, pastries, small window-view photographs, handwritten annotations, arrows, decorative doodles, and torn-paper notes. Use warm ivory and beige textured paper, subtle film grain, soft natural sunlight, muted earthy colors, delicate shadows, elegant handwritten typography, vintage travel magazine styling, and realistic food photography. Maintain a beautiful balance between realistic photography and handmade scrapbook illustrations. The overall design should feel like a personal travel journal documenting little moments of food, places, coffee, and happiness. Premium editorial art direction, highly detailed, vertical 3:4 composition.
Featured Prompts

Detailed storybook cutaway of a giant oak at dusk with three lamp-lit library rooms inside: a red armchair reading nook at the bottom, curved bookshelves with a ladder in the middle, and an attic with a brass telescope under a shingled roof, plus a spiral staircase, a lantern-lit rope bridge, falling orange autumn leaves, and a sunset sky.
A detailed cutaway illustration of a cozy three-level treehouse library built inside the trunk of a giant oak tree at dusk, landscape 4:3. The trunk is cut open to show three warm, lamp-lit rooms. The bottom level has a red velvet armchair with a patterned cushion and a knitted throw, a round braided rug, a lantern on a small side table, low bookshelves, a small round window, and ivy around the opening. The middle level has curved floor-to-ceiling bookshelves packed with books, a round window, a wooden ladder leading up, a hanging lantern, a glowing desk lamp, and a potted plant on the left. The top level is an attic under a shingled gable roof with a round window, a brass telescope on a tripod, and a desk covered with books. Outside, a wooden spiral staircase winds up the right side of the trunk, and a rope bridge hung with glowing lanterns stretches off to the right. Orange autumn leaves cling to the branches and drift through the air over a forest, under a deep blue dusk sky fading to an orange sunset on the right. Storybook style, fine ink lines, warm painted colors. No people, no text, no logos.

Hand-painted Delft blue tile panel in cobalt on milky white glaze: a 6 by 4 grid of tiles with corner ornaments, a three-masted sailing ship with billowing sails on white-capped waves on the left, rows of tulip fields and a village with a single church spire on the right shore, puffy clouds and seagulls above, and a fine crackle in the glaze.
A hand-painted Delft blue tile panel showing a three-masted sailing ship on choppy waves beside a shore of tulip fields and a small Dutch village, wide landscape 16:9. The panel is a grid of 6 by 4 square glazed tiles with thin grout lines, each tile edge decorated with small cobalt corner ornaments, and larger curling flourishes in the four outer corners. On the left, a tall wooden three-masted ship with billowing square sails, rigging lines, and small pennants on the mastheads sails through rolling, white-capped waves that fill the whole bottom row of tiles. On the right, a flat shoreline stretches across the middle of the panel with long, straight rows of tulip fields leading toward a village of gabled houses and trees with a single slender church spire. Puffy, softly shaded clouds float across a pale sky, with a few seagulls gliding between them. Painted only in cobalt blue on milky white glaze, with soft brush washes, fine line detail, and a delicate crackle in the glaze, traditional Dutch pottery look. No text, no lettering, no logos.

Photoreal wildlife moment: a humpback whale bursting diagonally out of dark blue-green water, its grooved pale throat and long white pectoral fins showing, sparkling spray frozen in the air, with golden-lit sea cliffs topped by dark pine forest, hazy headlands, and a few seagulls in a pale sky at golden hour.
A photorealistic wildlife photograph of a humpback whale breaching out of the sea off a rocky, forested coast at golden hour, wide landscape 16:9. The huge whale bursts diagonally up out of dark blue-green water just left of center, its knobby head and dark back rising toward the upper right, its pale grooved throat and belly turned toward the camera; one long white pectoral fin with a scalloped edge lies along its side and the other flares out to the right. Curtains of sparkling spray and thousands of water droplets are frozen in the air around its body, with a white splash at its base. Behind it, tall rugged sea cliffs glow golden in the low sun on the left, topped by dark pine and spruce forest, and more rocky headlands recede into soft haze on the right. A few seagulls fly in the upper right under a pale, clear sky. Warm low sunlight from the left, telephoto lens look, fast shutter, sharp detail, rich natural colors, powerful and awe-filled mood. No boats, no people, no text, no logos.
Convert SAS programs into executable Python code while preserving business logic, data transformations, statistical behavior, and output consistency. Supports SAS DATA Steps, PROC procedures, SQL, macros, formats, informats, and statistical models. Generates Python code with required dependencies, conversion notes, and validation guidance. Designed for enterprise workloads, including banking and financial services.
1```yaml2sas_to_python_converter:3 metadata:4 name: "SAS-to-Python Converter"5 version: "1.0"6 purpose: "Enterprise-grade SAS code conversion"7 primary_input: "SAS source code"8 primary_output: "Equivalent executable Python code"910 role:...+679 more lines

Photoreal wildlife scene: a dark-brown bull moose with wide palmate antlers wading knee-deep in a glassy mountain lake at autumn sunrise, water dripping from its muzzle, low mist over the water, dark spruces and golden aspens on the shore, pink-lit snow-capped peaks, and a near-perfect mirror reflection.
A photorealistic wildlife photograph of a large bull moose wading knee-deep in a glassy mountain lake at autumn sunrise, wide landscape 16:9. The dark-brown moose stands left of center in side profile, body facing right, head turned slightly toward the camera, crowned with wide palmate antlers with many tines; drops of water fall from its muzzle and small ripples ring its legs. A thin layer of low white mist hangs over the still water. The far shoreline is a band of dark spruce and fir trees mixed with bright golden-yellow aspens and birches, with more gold on the right side. Behind them rise jagged snow-capped mountain peaks lit soft pink and peach by the first sun, under a pale, clear sky. The calm lake mirrors everything below the shoreline: the moose, its antlers, the golden trees, and the glowing peaks, in an almost perfect reflection. Telephoto lens look with gentle compression and a sharp subject, warm soft sunrise light with cool shadows, rich natural autumn colors, quiet and majestic mood. No people, no text, no logos.

Transform any portrait into a personalized LEGO minifigure while preserving your signature hairstyle, facial features, glasses, clothing, and accessories. ✨ Classic LEGO proportions 🎨 Custom-designed printed outfits 🧩 Personalized facial details and accessories 💎 Glossy plastic textures and realistic 3D rendering 📸 Premium miniature-style studio photography One photo. One miniature version of you. A whole new way to see yourself in LEGO form. Upload your photo and build your minifigure!
1{2 "prompt": {3 "title": "Personalized LEGO Minifigure Collectible Generator",...+165 more lines

Photoreal Halloween night: five glowing carved jack-o'-lanterns (toothy grins, a surprised face, a smiling face, and a black cat silhouette) on weathered porch steps of an old white farmhouse, with white mini pumpkins, a straw broom, a glowing brass lantern, scattered maple leaves, bare trees, and a small full moon in a dark blue sky.
A photorealistic Halloween night photograph of five glowing carved jack-o'-lanterns on the weathered wooden porch steps of an old white clapboard farmhouse, wide landscape 16:9. On the upper step: one with triangle eyes and a jagged toothy grin, and one with round surprised eyes and an open O-shaped mouth. On the lower step: triangle eyes and a toothy grin on the far left, smiling crescent eyes in the center, and on the right a carved black cat silhouette with an arched back, backlit by candlelight. Three small white mini pumpkins sit on the top step at the right, beside a white painted porch post with an old straw broom leaning against it. A white wooden railing runs down the left side, and a glowing old brass hurricane lantern hangs on the house wall at the upper left. Dry orange and red maple leaves are scattered over the worn grey steps. Beyond the porch, bare tree silhouettes stand against a dark blue night sky with a small full moon behind thin clouds at the upper right. Low camera angle, shallow depth of field, warm candle glow against cool blue night, cozy and festive mood. No people, no text, no logos.
Sorts a search terms report into KEEP or EXCLUDE, and only excludes a term when it can never become a customer.
You are a senior Google Ads specialist auditing a search terms report. Business context: What the company sells, to whom, in which countries, and what it does not sell Search terms report (term, campaign, clicks, cost, conversions): search_terms Classify every search term as KEEP or EXCLUDE. There is no third category. Rules: 1. Judge what the person wants, not the words they typed. A clumsy phrasing can still come from a buyer. 2. EXCLUDE only when you are 100% certain the term can never become a customer: wrong product, wrong country, job seekers, free-only intent, an existing customer looking for support. 3. Any doubt means KEEP. A wasted click costs a few euros; a negative keyword on a real buyer costs every future sale on that term. 4. A term that already converted stays KEEP. 5. Do not invent context. If the business context does not settle it, the term stays KEEP. Output: 1. A table: term | decision | intent you read | reason in one sentence. 2. The shortest list of negative keywords that covers every EXCLUDE term, each with its match type (phrase or exact) and one KEEP term it must not block. 3. The total cost of the EXCLUDE terms, calculated from the report.

Photoreal wildlife shot: a snow leopard stands on snow-dusted dark rocks in the left foreground, body angled to the right and head turned to look straight at the camera, smoky grey fur with black rosettes and a long ringed tail, above a white sea of clouds with a range of jagged snowy Himalayan peaks glowing pink and gold at sunrise under a pale lavender sky.
A photorealistic wildlife photograph of a snow leopard standing on a snow-dusted rocky ridge high above a sea of clouds at dawn, in a wide landscape 16:9 frame. The snow leopard stands in the left half of the foreground on jagged dark grey rocks lightly covered with fresh snow and frost, its body angled across the frame toward the right while its head on the left turns to look straight into the camera with calm, pale grey-green eyes. Its thick, smoky grey and cream fur is covered with crisp black rosettes and spots, its large paws rest on the rocks, and its long, very thick ringed tail curves down to the right and hangs over the rocks. Behind the ridge, a soft white sea of clouds fills the valleys below. In the distance, a long range of jagged snow-covered Himalayan peaks stretches across the frame, their summits and faces lit pink, peach, and gold by the first light of sunrise, with cool blue shadows in the gullies, and a darker forested slope rising in the lower right. The sky is a clear pale blue fading to soft lavender near the horizon. Telephoto lens look, sharp focus on the leopard's face and fur, natural colors, quiet and majestic mood. No people, no text, no logos.
Latest Bots
Finds code nobody uses anymore: unused files, exports, routes, styles and dependencies, with proof for each one, and suggests small safe pull requests to remove them.
Keeps your family's health records in one private place: vaccines, allergies, medicines, test results and checkup dates for every family member, ready when a doctor asks.
Gets a startup ready for investor due diligence: builds a clean data room checklist, finds missing or outdated documents, and answers investor requests from one place.
Catches quality drops when you change an AI feature's prompt or model: runs your test cases, compares old and new answers side by side, and tracks cost and speed.
Runs a school or activity carpool with other families: builds a fair driving rota, handles swaps and sick days, and reminds each driver the night before.
Reviews team expense reports against your company policy before you approve them: checks receipts, limits and duplicates, and explains every flag in plain words.
Latest Prompts

A short fill-in-the-blanks image prompt for charming paper cut-out storybook characters: name any subject and a simple action and get a full-body character built from layered handmade paper shapes with visible cut edges, soft shadows between layers, flat colorful paper tones and a gentle stop-motion, folk-tale feel on a clean white background. Great for children's book spreads, greeting cards and stickers.
Paper cut-out animation of a little brown bear in a patchwork apron watering a tall sunflower, full-body character, layered paper shapes, handmade paper texture, visible cut edges, soft shadows between layers, flat colorful paper tones, slightly imperfect handcrafted details, minimal paper-collage scenery, charming folk story aesthetic, gentle stop-motion feel, poetic children’s book atmosphere, clean white background
A production-grade 15-second, four-shot video prompt: an old seaman rows alone through dense dawn fog on cold northern waters until a 140-meter three-masted black-sailed galleon slowly emerges ahead, revealed in layers from a side close-up to two wide shots built to sell its enormous scale. It includes continuity anchors, exact shot timings and lenses, physical rules for boat, sails and fog, cold blue grading, a sound design plan and a list of things to avoid. No battle, no ghosts, no dialogue.
[Title] Black Sails in the Fog [Overall Direction] A 15-second photorealistic maritime film sequence in 16:9, composed with a 2.39:1 widescreen sensibility and natural 24fps motion. The sequence contains four distinct shots. The story takes place in cold northern European waters during the medieval period. An elderly white European man rows a small wooden boat alone into dense fog, where he discovers a black-sailed pirate ship far larger than any ordinary vessel. The sequence focuses on the ship being revealed gradually through the fog and on the extreme difference in scale between the fragile rowboat, the elderly man, and the enormous pirate ship. The atmosphere is cold, silent, and oppressive, but there are no supernatural creatures, ghosts, magical effects, or battles. All timber, canvas, ropes, seawater, fog, and human movement must have convincing weight and physical behavior. The footage should feel captured with a full-scale practical sailing ship, a real ocean location, and a cinema camera—not like a video-game cutscene or fantasy concept art. [Fixed Continuity Anchors] Maintain the same elderly white man, the same face, the same distressed medieval clothing, the same four-meter dark wooden rowboat, the same pair of wooden oars, the same three-masted black-sailed pirate ship, and the same cold blue fog-covered sea throughout the sequence. The pirate ship must remain ahead of the old man and slightly to the right. Its bow faces the rowboat at a slight angle. The ship is almost motionless, moving only slowly with the long ocean swell. The rowboat continues moving toward the pirate ship. Wind, waves, and fog consistently move from the left side of the frame toward the right. Cold morning light always comes from the upper left. The pirate ship must retain exactly three main masts, layered black sails, a high forecastle and sterncastle, a long bowsprit, a dark wooden hull, and dense rigging in every shot. Its design must not change between cuts. [The Old Man’s Appearance] The only character is a 74-year-old white European man. He is thin and slightly stooped, but his shoulders and arms retain the wiry strength of a lifetime of physical labor. His pale, coarse skin is deeply marked by cold wind, saltwater, and years of sun exposure. He has prominent cheekbones, a high and slightly crooked nose, deep-set eyes, and an uneven gray-white beard covering his cheeks and chin. His eyes are gray-blue, naturally moist, with small living catchlights. His forehead, eye corners, and mouth carry authentic age lines. His gray-white hair reaches the back of his neck, dampened by sea fog and loosely tied with an old leather cord. Several wet strands cling to his temples and ears. His skin must retain pores, age spots, and wrinkles. Do not beautify, smooth, or artificially dirty his face. [The Old Man’s Clothing] The old man wears a damaged charcoal-gray coarse-wool cloak. Its edges are frayed and repaired with a few simple hand-sewn patches. Moisture from the sea fog has darkened the fabric. Beneath the cloak is a dirty gray-white linen tunic with a loose collar and worn cuffs. A weathered dark-brown leather belt secures the clothing at his waist. He wears dark gray-brown woven trousers and cracked old leather boots. A short length of faded dark reddish-brown wool is tied around his neck. This scarf is the only faint warm accent in the otherwise cold blue image. The clothing must contain no modern stitching, zippers, plastic, branding, reflective strips, or modern flotation equipment. [Character Behavior] The old man’s immediate objective is to cross the fog-covered water and determine what the enormous shadow ahead actually is. His obstacles are the dense fog and the long ocean swell that repeatedly alters the rowboat’s direction. He rows with the practiced economy of an aging seaman. His center of gravity remains low, his feet planted firmly against the bottom of the boat. His lower back moves first, leaning slowly backward; his shoulders and arms then pull the two wooden oars toward his chest. The strength of his left and right arms differs slightly. Each stroke is accompanied by natural breathing, shoulder-blade movement, friction from the oarlocks, and the forward inertia of the boat. His movement is no longer light, but it remains experienced and efficient. When the deep groan of a massive wooden hull reaches him through the fog, his ongoing rowing stroke slows. The left oar blade remains submerged and creates resistance while the right oar stops in midair. Seawater drips continuously from its edge. His eyes leave the surface of the sea first and move toward the shadow above. He gives one slow blink. Only then does his chin rise and his head follow the direction of his gaze. His lips remain closed, his breathing pauses briefly, and his hands tighten around the oar handles. He does not shout or flee. After confirming that an enormous vessel is hidden in the fog, he adjusts his seated posture, braces both feet, and resumes rowing slowly toward the shadow. His performance conveys caution, exhaustion, and an irresistible need to know—not theatrical fear. [The Rowboat] A medieval wooden rowboat approximately 4.2 meters long, constructed from dark, aged oak. The plank seams and wooden fastening pegs are structurally believable. Most of the black waterproof tar coating has worn away, revealing gray-brown wood grain beneath. The gunwales show wear from years of use. The interior remains sparse, containing only one coil of old hemp rope. Do not add fishing nets, lanterns, barrels, crates, or decorative objects. The two wooden oars must retain the same dimensions, color, material, and position throughout the sequence. The boat rises, falls, and rolls gently with long ocean swells approximately one to one-and-a-half meters high. It must have convincing weight and inertia. Its bow creates a small amount of dark spray as it cuts through the water. It must not float or bounce like a lightweight toy. [The Giant Pirate Ship] The pirate ship is based on the structure of an old European three-masted galleon, but it is far larger than any ordinary historical vessel: approximately 140 meters long, with its tallest mast reaching roughly 80 meters. Its scale is extraordinary, but its timber construction, structural loading, rigging connections, and canvas movement must remain physically believable. The hull is built from almost black, heavily weathered oak. Its timbers are wide and heavy, with visible plank seams and matte iron reinforcement along structural edges. The bow is tall and projects forward with oppressive weight, ending in a long, narrow bowsprit. The stern rises into a multi-level sterncastle. Two rows of dark gunports line the sides of the hull, but no cannons are fired. Salt deposits, dark wet marks, and small amounts of sea growth appear around the waterline. The ship is not rotten and does not glow. Three tall main masts carry multiple levels of square black sails. The canvas is heavy and aged, damaged by wind and salt, with naturally torn edges and visible repairs. The sails are not pure black; they contain subtle layers of deep blue-gray, charcoal, and weathered brown. Dense hemp rigging connects the masts, yards, gunwales, and corners of the sails according to authentic sailing-ship construction. A small faded black flag flies from the highest mast. It bears only a simple, weathered bone-white skull-and-crossbones symbol with no written words. No crew members, torches, or artificial lights are visible on deck. The vessel feels unnaturally quiet but remains completely physical and opaque, never transparent or ghostlike. [Ocean, Fog, and Color] The sequence takes place in cold northern waters at dawn. The boundary between sea and sky is nearly erased by thick blue-gray marine fog. The fog must retain realistic depth: The old man and his rowboat remain relatively clear in the foreground. Water several dozen meters away loses contrast. Only portions of the pirate ship are visible in the middle distance. The upper masts and corners of the sails disappear back into the fog. The seawater consists of low-saturation slate blue, deep blue-gray, and faint gray-green tones. Wave crests do not produce unnaturally bright white foam. The sky is pale cold blue mixed with pearl gray. The pirate ship is near-black wood with deep blue-gray sails. The old man’s faded reddish-brown scarf provides the only very weak warm accent. Wind slowly pushes the fog across the ship, allowing its fixed structure to appear in layers. The fog changes what is visible; the vessel itself never changes shape. The fog must not behave like fast-moving smoke, and it must not make the entire image uniformly blurry. [Shot One | 0–3.5 Seconds | Side Close-Up of the Old Man] A 65mm cinema lens. The camera floats beside the left side of the rowboat, slightly below the old man’s shoulder height, rising and falling gently with the same ocean swell. The old man’s side profile occupies the left half of the frame. The background contains only defocused cold-blue seawater and layered fog. The pirate ship must not appear yet. The old man is already midway through a rowing stroke. His lower back moves slowly backward as both hands pull the oars toward his body. Damp gray hair clings to his face, and the coarse wool cloak responds subtly to the sea wind. A broad swell lifts the wooden boat, and the camera rises with it in a restrained, physically grounded movement. One distant, low note from an old bronze ship’s bell travels through the fog, followed by the deep groan of an enormous timber structure under pressure from the sea. The old man’s rowing motion gradually stops. One oar blade remains submerged while the other pauses in midair. Water drips from the edge of the blade. His eyes move toward the upper-right side of the frame first. He gives one slow blink, then gradually raises his head. Cut directly before the head movement is fully completed. Do not reveal what he sees in this shot. [Shot Two | 3.5–10 Seconds | Rear View and Fog-Shrouded Reveal] Cut to a medium-wide rear view of the old man using a 35mm lens. The camera is positioned close to the stern of the rowboat at approximately shoulder height. Preserve the direction of the boat, the old man’s seated position, the direction of the waves, and the direction of the cold light from the previous shot. The old man occupies the lower center of the frame with his back toward the camera. An enormous dark shape rises inside the fog ahead. At first, only the towering bow and one vague vertical mast line can be distinguished. As the fog moves laterally, three enormous masts, layered black sails, dense rigging, and elevated deck structures gradually become visible. The tops of the masts remain hidden. The pirate ship must not appear completely in a single instant. Its structure remains fixed; only its visibility changes as fog passes across it. After a short pause, the old man resumes rowing. He lowers his center of gravity and completes one slow, deliberate stroke, then begins a second. The rowboat moves forward with the swell. Each time the boat rises onto a wave crest, the ship’s shadow appears taller. When the boat descends into a trough, the lower part of the pirate ship is briefly hidden by water and fog. As the old man continues forward, the bow, the dark gunports, and the lowest black sails become gradually clearer. High-altitude wind causes the enormous black sails to fill and relax with delayed, heavy movement. The rigging tightens and loosens slightly. It must not swing wildly. The pirate ship remains almost motionless, advancing into the swell at an extremely slow speed. [Shot Three | 10–12.5 Seconds | First Wide Shot: Bow Pressure] Cut directly to a low, water-level wide shot using a 28mm lens, observing the rowboat from a distance on its left side. A defocused dark-blue wave crest occupies the foreground. The old man and rowboat sit in the middle ground. The pirate ship’s towering bow, lower black sails, and fog-covered masts dominate the background. The rowboat occupies only about 2% of the frame width. The old man is even smaller, but his charcoal cloak and faded reddish-brown scarf remain consistent. The pirate ship’s bow rises from the sea like a dark timber cliff. Large hull planks, gunports, and matte iron reinforcement provide believable scale references. The foreground wave briefly hides the lower half of the rowboat. As the crest falls, the boat reappears in the same spatial relationship to the ship. The vessel’s position, size, and structure remain stable. The upper black sails and masts are partially swallowed by blue-gray fog. This shot emphasizes the vertical height of the bow and the oppressive distance between the small boat and the ship. [Shot Four | 12.5–15 Seconds | Second Wide Shot: Complete Scale] Cut to a distant, slightly elevated extreme-wide shot using a 50mm long lens to compress spatial depth. The camera remains stable without a fast aerial dive, orbit, or rotation. The enormous three-masted pirate ship appears from a front-side angle and dominates the center-right of the frame. Most of the full hull, long bowsprit, high sterncastle, three main masts, and layered black sails enter the frame. The lower hull remains relatively clear, while the upper masts and sail corners fade in and out of the dense fog. The black pirate flag moves slowly at the top of the highest mast. The old man and his boat remain ahead of the ship and slightly to the left, reduced to a tiny dark mark on the cold-blue sea. The rowboat continues rising and falling with the swell as the old man rows slowly. The pirate ship remains heavy, silent, and almost immovable. Maintain a safe distance between both vessels. No collision occurs. Large areas of cold-blue water and empty fog surround the subjects. There is no land, no other vessel, and no modern structure on the horizon. Hold the final composition for approximately half a second, keeping the old man, the small boat, and the enormous black sails visible together. [Editing Logic] The sequence contains four shots and three direct cuts. The first cut moves from the old man raising his head in side profile to the enormous ship shadow seen from behind him. The second cut moves from the old man continuing to row into the low water-level wide shot, revealing the height of the bow. The third cut moves to a farther, slightly elevated extreme-wide shot, revealing the total length of the pirate ship and the surrounding ocean. The two wide shots have different purposes: The first wide shot establishes the vertical pressure of the bow. The second wide shot reveals the full vessel and the final scale relationship between the ship, the ocean, and the rowboat. Do not insert close-ups of skulls, cannons, eyes, rigging hardware, or pirate crew. [Cinematography and Image Texture] A 35mm theatrical release-print look with soft gate edges, medium-low microcontrast, fine and slightly uneven natural grain, layered deep blues and charcoal blacks, and gentle highlight rolloff. Fog and slight condensation along the edge of the lens are the only dominant image imperfections. Do not simultaneously add rain, dust, smoke, sparks, or strong lens flares. The camera retains small amounts of ocean movement and inertia while keeping the horizon generally stable. Close shots use restrained depth of field. Distant contrast decreases naturally through atmospheric perspective. The pirate ship must not appear unnaturally sharp inside the fog, but the entire image must not be covered by a uniform blur filter. [Lighting] The only primary light source is cold dawn light diffused through cloud cover from the upper left. A faint silver-blue edge light touches the left side of the old man’s face, wet hair, wooden oars, and gunwale. The side of the pirate ship facing the light reveals low-contrast cold-gray structure. The backlit side falls toward deep blue-black while preserving readable timber layers. Do not use moonbeams, torchlight, ghostly green illumination, magical glow, lightning, or warm sunset light. [Sound] No dialogue, narration, or musical score. The soundscape consists of wooden oars entering the sea, low water displacement, friction from the oarlocks, creaking rowboat planks, the old man’s controlled but strained breathing, wind softened by fog, one distant bronze ship-bell note, the deep groan of the enormous wooden hull under wave pressure, rigging tightening, and black sails filling slowly with wind. The bell and timber groan are heard before the pirate ship appears in Shot One. Low wooden resonance expands when the ship is revealed in Shot Two. In the final two wide shots, ocean waves, wind, and large areas of silence again dominate the soundscape. [Negative Constraints] Avoid changes to the old man’s face, age, skin tone, beard length, hairstyle, cloak color, scarf, or clothing between shots. Do not make him suddenly younger. Avoid modern clothing, armor, aristocratic costume, stereotypical pirate clothing, modern life jackets, plastic, zippers, modern shoes, wristwatches, firearms, or electronic equipment. Avoid incorrect anatomy, distorted body proportions, extra arms, extra fingers, changing numbers of oars, hands passing through oar handles, rigid rowing, mechanical looping movements, sliding feet, exaggerated screaming, an open-mouthed expression of terror, theatrical fear, heroic posing, empty eyes, or frozen eyes. Avoid changes to the rowboat’s dimensions, color, design, or material. Do not transform the boat into a modern lifeboat, fishing vessel, sailboat, or plastic boat. Avoid weightless floating, sudden sideways movement, violent bouncing, capsizing, flooding, breaking apart, or colliding with the pirate ship. Avoid changes to the pirate ship’s design, length, hull shape, bow, stern, or color. Maintain exactly three main masts. Do not allow masts to appear or disappear. Avoid changing the black sails’ color, melting canvas, incoherent rigging, swapped bow and stern, moving gunports, shipping containers, steam engines, modern motors, steel superstructures, or electric lights. Avoid a cartoon pirate ship, theme-park attraction, miniature model, plastic wood grain, glowing ghost ship, transparent hull, skeleton crew, ghosts, zombies, sea monsters, tentacles, flying ships, magic, green ghost flames, burning sails, cannon battles, explosions, or lightning. Avoid fog disappearing suddenly between cuts or changing color. Do not make the fog behave like fast-moving smoke. The pirate ship must not deform as fog passes over it. Avoid tsunami waves, vertical walls of water, gelatinous water, fabric-like water, smoke-like water, changing wave direction, sudden daylight changes, warm sunset colors, or night. Avoid fast editing, additional shots, location changes, time jumps, flash transitions, dissolves, morphing transitions, camera teleportation, high-speed drone dives, rotating around the ship, sudden zooms, dolly zooms, slow motion, or speed ramping. Avoid video-game cutscenes, fantasy concept art, pirate-themed advertising, uniform sharpness across the entire frame, excessive sharpening, excessive skin smoothing, oversaturation, commercial teal-and-orange grading, neon, lasers, decorative light beams, excessive lens flare, text, subtitles, brand logos, and modern watermarks. Allow the simple skull-and-crossbones pirate flag at the top of the highest mast, but do not place any readable text on the flag.
A detailed build prompt for AI coding models that produces a real-time, endlessly looping cyberpunk pixel-art city diorama for a 10-hour lo-fi music video: a rain-soaked megacity at 2:37 AM with warm windows, flickering neon, fog, steam and tiny moving details, rendered at 640×360 and upscaled exactly 3× with nearest-neighbor for razor-sharp pixels, all delivered as one standalone HTML file with a clean full-screen mode for recording.
# Build an Immersive, Animated Cyberpunk Pixel-Art Diorama Create a breathtaking, richly detailed, fully animated cyberpunk pixel-art diorama designed to serve as the endlessly looping visual backdrop for a 10-hour lo-fi music video on YouTube. I want something genuinely mesmerizing. A tiny, self-contained world that feels alive. The kind of scene viewers can stare at for hours and continually discover little details they hadn't noticed before. ## Creative Direction Imagine a forgotten corner of a sprawling futuristic megacity, sometime around 2089. It's 2:37 AM. Rain has been falling for days. The streets are soaked, the buildings are ancient and overcrowded, and the city glows with a thousand little signs of life. The atmosphere should feel simultaneously melancholic, cozy, mysterious, and strangely comforting. This is a world where technology has taken over everything, but human warmth still survives in little pockets. Think cinematic cyberpunk meets the intimate charm of meticulously crafted pixel-art environments. Don't settle for a generic neon skyline. Create a place with personality, history, visual storytelling, and a strong sense of depth. ## Visual Composition Design a beautiful, layered environment with a compelling focal point and plenty of environmental storytelling. Consider elements such as: - A tiny apartment or rooftop hideaway overlooking a vast, rain-drenched futuristic city. - Glowing windows revealing glimpses of other people's lives. - Holographic advertisements, animated signage, and distant traffic. - Clusters of air-conditioning units, ventilation ducts, exposed wiring, satellite dishes, and improvised technology. - Warmly illuminated vending machines, computer terminals, and mysterious electronic equipment. - Rainwater collecting in puddles and cascading from rooftops. - Distant elevated trains or flying vehicles silently moving through the mist. - Tiny human silhouettes occasionally visible behind windows. - Overgrown plants reclaiming corners of this technological world. These are creative possibilities, not a mandatory checklist. Make deliberate artistic choices. Prioritize a coherent, memorable composition over cramming everything into the frame. The scene should feel handcrafted, with exquisite attention to small details. ## Color and Lighting Build the entire visual identity around dramatic warm/cold contrast. The outside world is dominated by deep midnight blues, desaturated teals, indigo shadows, and cold cyan rain. Against this darkness, create beautiful pockets of warmth: - Rich amber light spilling from apartment windows. - Soft orange and golden illumination from lamps and electronics. - Occasional saturated magenta, electric violet, and crimson neon. - Warm reflections shimmering across cold, wet surfaces. Use lighting to tell a story. Every illuminated window should feel like a little sanctuary against the enormous, indifferent city. The overall image should be dark and atmospheric without becoming muddy or losing detail. ## Animation: Make the World Feel Alive This is absolutely critical. I don't want a static illustration with a generic rain effect. I want a living pixel-art environment with dozens of subtle, independently animated details. Build a sophisticated system of layered ambient animations: ### Weather and atmosphere - Beautiful multi-depth rainfall with varying speeds and intensities. - Occasional heavier gusts that push rain diagonally. - Thin layers of drifting fog and atmospheric haze. - Smoke rising from vents and chimneys, curling into the night. - Water dripping from ledges and splashing into puddles. - Ripples spreading across reflective surfaces. ### Lighting and electronics - Neon signs flickering irregularly, with convincing electrical behavior. - Screens cycling through mysterious data and advertisements. - Small indicator lights blinking asynchronously. - Warm interior lights gently fluctuating. - Occasional sparks from damaged electrical equipment. - Animated reflections responding to nearby light sources. ### Environmental life - Slowly rotating ventilation fans. - Distant vehicles passing behind buildings. - Tiny silhouettes moving behind illuminated windows. - Steam escaping from pipes. - Hanging cables or signs subtly swaying in the wind. - Occasional small, unexpected details that reward prolonged viewing. Every effect should have its own rhythm. Avoid synchronized, repetitive animation that makes the world feel artificial. ## Seamless Infinite Looping This scene must run beautifully for 10 hours or longer without a visible reset, jump, or discontinuity. Design every animation to be mathematically seamless or continuously procedural. Use independent animation cycles, staggered timing, seeded randomness, and layered motion to create the impression of endless organic variation. No sudden resets. No obvious looping rain textures. No visible animation seams. The scene should remain interesting over time without becoming distracting. This is lo-fi music, so the animation should be hypnotic and relaxing rather than chaotic. Reserve dramatic events for rare, carefully controlled moments. ## Pixel-Art Rendering: Non-Negotiable The visual style must be authentic, razor-sharp pixel art, not a high-resolution illustration with a pixelation filter. Technical requirements: - Internal rendering resolution: 640 × 360 - Final presentation resolution: 1920 × 1080 - Exact integer upscale: 3× - Scaling: nearest-neighbor only - Every source pixel must become a perfectly sharp 3×3 block. - Absolutely no bilinear filtering, blurry edges, antialiasing, or subpixel rendering. - No smooth vector graphics mixed into the pixel-art scene. - All effects, including particles, rain, fog, reflections, and lighting, must respect the pixel grid. - Maintain consistent pixel density across every visual layer. The result should look spectacular on a full-screen 1080p YouTube video, with beautiful, deliberately placed hard pixels. ## Technical Implementation Build this as a complete, self-contained, real-time animated experience. Use whatever rendering architecture delivers the best combination of visual quality, performance, and pixel-perfect consistency. HTML5 Canvas, WebGL, or a hybrid approach are all acceptable. Prioritize: - Smooth, efficient rendering at 1080p. - Deterministic, seamless animation. - Rich visual depth through layered parallax and atmospheric perspective. - Procedural particle systems for weather and environmental effects. - Carefully controlled lighting, blending, and reflections. - Stable performance over extremely long runtimes. - A fixed camera with no unintended movement or jitter. - Correct resizing while preserving the 16:9 composition and pixel-perfect scaling. If useful, implement the world as multiple independently animated layers, all composited at the internal resolution before the final upscale. Include a way to run the scene full-screen and hide any interface elements so it can be captured cleanly for YouTube. ## Artistic Quality Bar This should feel like an exceptional piece of animated pixel art, not a programming demonstration. Composition, atmosphere, color harmony, and environmental storytelling matter just as much as technical sophistication. Avoid generic procedural rectangles masquerading as buildings. Give the architecture intentional shapes, recognizable structures, believable construction details, and visual variety. Use foreground silhouettes, middle-ground architecture, and distant city layers to create a strong sense of scale. Every element should contribute to a cohesive world. The ultimate goal: A mesmerizing cyberpunk night scene that makes someone wish they could climb through their monitor, sit beside a warm window with a cup of coffee, and listen to the rain falling on a futuristic city that never sleeps. Make it beautiful. Make it atmospheric. Make it technically excellent. And above all, make it feel alive. Don't just describe how you would build it. Actually implement the complete working experience. Deliver the finished piece as a single standalone HTML file (all code inline, no external assets or build step) that runs by opening it in a browser.
Opinionated conventions for designing reliable, typed and easy-to-use API client SDKs.
# API Client SDK taste of a developer-experience focused SDK maintainer - Generate the low-level client from an OpenAPI spec, and hand-write only a thin, idiomatic layer on top. Confidence: 0.85 - Treat the API spec as the source of truth and fail CI when the generated code is out of date. Confidence: 0.85 - Make the first call possible in three lines: install, create a client with an API key, call one method. Confidence: 0.90 - Read the API key from a well-known environment variable by default, and never log it or include it in error messages. Confidence: 0.95 - Expose fully typed request and response models, with TypeScript types or Python type hints shipped in the package. Confidence: 0.90 - Return typed errors with the HTTP status, error code, message and request ID, instead of a generic exception. Confidence: 0.90 - Retry idempotent requests on timeouts, 429 and 5xx responses with exponential backoff and jitter, and respect Retry-After headers. Confidence: 0.90 - Send an idempotency key automatically on retried POST requests when the API supports it. Confidence: 0.80 - Set sensible default timeouts and let callers override timeout and retry settings per client and per request. Confidence: 0.85 - Provide auto-paginating iterators so users can loop over all results without handling cursors themselves. Confidence: 0.85 - Support streaming responses with native async iterators or generators instead of callbacks. Confidence: 0.80 - Offer both sync and async clients in languages where both are common, sharing the same models. Confidence: 0.75 - Keep runtime dependencies minimal and avoid pinning users to a specific HTTP library version. Confidence: 0.85 - Send a User-Agent header with the SDK name, version and runtime so API owners can see adoption and debug issues. Confidence: 0.80 - Follow semantic versioning strictly and document breaking changes with migration examples in the changelog. Confidence: 0.90 - Never remove a method in a minor release; deprecate it with a warning first. Confidence: 0.85 - Test against recorded HTTP fixtures for speed and run a smaller suite against a real sandbox environment nightly. Confidence: 0.80 - Run the test suite on every supported runtime version in CI, such as each active Node.js LTS or Python release. Confidence: 0.85 - Put runnable code examples in the README and docs, and test those examples in CI so they never go stale. Confidence: 0.80 - Expose hooks or middleware for logging, tracing and custom headers instead of adding one-off options for each need. Confidence: 0.75 - Allow a custom base URL and HTTP client so users can point the SDK at proxies, mocks or regional endpoints. Confidence: 0.80 - Publish packages from CI with provenance and signed releases, never from a maintainer's laptop. Confidence: 0.75
Opinionated conventions for structuring, testing and shipping dbt analytics projects.
# dbt Analytics Project taste of a careful analytics engineer - Layer models into staging, intermediate and marts folders, with one staging model per source table. Confidence: 0.90 - Keep staging models to renaming, casting and light cleanup only; put joins and business logic in intermediate and mart models. Confidence: 0.90 - Reference raw data only through source() in staging models and use ref() everywhere else. Confidence: 0.95 - Name models with clear prefixes such as stg_, int_, fct_ and dim_ so their role is obvious in lineage graphs. Confidence: 0.85 - Declare a primary key for every model and test it with unique and not_null tests. Confidence: 0.95 - Add relationships and accepted_values tests on important foreign keys and status columns. Confidence: 0.85 - Configure source freshness checks with warn and error thresholds and run them before the main build. Confidence: 0.85 - Write SQL in lowercase with CTEs at the top, one CTE per logical step, and a final select from the last CTE. Confidence: 0.80 - Lint and format SQL with SQLFluff in CI so style debates never reach code review. Confidence: 0.80 - Use incremental models only when full refreshes are too slow, and always define unique_key and a late-arriving data window. Confidence: 0.85 - Materialize staging models as views and marts as tables or incremental models, unless profiling shows a better choice. Confidence: 0.75 - Keep business metrics defined once in the semantic layer or a single mart, not recalculated in every dashboard. Confidence: 0.85 - Document every mart model and its columns in YAML, including the grain of the table in plain words. Confidence: 0.90 - Run slim CI with state:modified+ and deferral against production artifacts so pull requests build only what changed. Confidence: 0.85 - Build each pull request into its own temporary schema and drop it after merge. Confidence: 0.80 - Pin dbt and package versions in packages.yml and the project requirements, and upgrade them deliberately. Confidence: 0.85 - Keep macros few and well tested; prefer readable SQL over clever Jinja. Confidence: 0.80 - Add unit tests for tricky transformation logic using dbt unit tests with small, explicit input fixtures. Confidence: 0.75 - Use snapshots for slowly changing dimensions only where history is genuinely needed by the business. Confidence: 0.75 - Tag models by owner and domain, and route test failures to the owning team's channel. Confidence: 0.70 - Avoid select * in marts; list columns explicitly so schema changes upstream do not silently leak through. Confidence: 0.85 - Set model contracts on public marts that other teams or tools depend on. Confidence: 0.70
Opinionated conventions for building accessible, themeable and well-versioned design systems and component libraries.
# Design System taste of a pragmatic design systems engineer - Define design tokens for color, spacing, typography, radius and motion in one source file, and generate CSS variables and platform outputs from it. Confidence: 0.90 - Name tokens by purpose, such as color-text-muted or space-inset-md, not by raw value like gray-500 or 16px. Confidence: 0.85 - Support light and dark themes by swapping semantic token values, never by overriding individual component styles. Confidence: 0.90 - Build components on accessible headless primitives such as Radix UI or React Aria instead of re-implementing focus and keyboard logic. Confidence: 0.85 - Meet WCAG 2.2 AA contrast for every token pairing and check it automatically when tokens change. Confidence: 0.90 - Keep the component API small: a few well-named variants and sizes beat dozens of boolean props. Confidence: 0.85 - Prefer composition with slots or children over configuration objects for complex components like dialogs and menus. Confidence: 0.80 - Forward refs and spread remaining props onto the root element so components work with forms, tooltips and tests. Confidence: 0.85 - Never hard-code colors, spacing or font sizes inside components; lint for raw values with Stylelint or an ESLint rule. Confidence: 0.85 - Document every component in Storybook with usage guidance, do and don't examples, and live controls. Confidence: 0.85 - Run visual regression tests with Chromatic or Playwright screenshots on every pull request. Confidence: 0.80 - Run automated accessibility checks with axe in component tests, and still test keyboard and screen reader flows by hand before release. Confidence: 0.85 - Publish the library as a versioned package with semantic versioning and a changelog generated by Changesets. Confidence: 0.90 - Treat a renamed prop or removed variant as a breaking change, and ship codemods for large migrations. Confidence: 0.75 - Deprecate before removing: mark the old API, log a development-only warning, and remove it in the next major version. Confidence: 0.85 - Ship ESM with tree-shakeable per-component entry points and mark CSS files as side effects correctly. Confidence: 0.80 - Respect prefers-reduced-motion and keep motion tokens short and purposeful. Confidence: 0.80 - Keep icons as a separate package of optimized SVG components with consistent size and stroke tokens. Confidence: 0.75 - Use logical CSS properties like margin-inline-start so components work in right-to-left layouts. Confidence: 0.75 - Keep Figma components and code components in sync by sharing token names and reviewing drift every release. Confidence: 0.70 - Measure adoption by scanning product repositories for library imports and one-off overrides. Confidence: 0.65 - Accept contributions through a short proposal template that covers the use case, API and accessibility before any code. Confidence: 0.70
Plans car maintenance from your service schedule and service history - estimates how far you drive from odometer readings, works out what is overdue, due soon or next due by distance or time (whichever comes first), bundles items into fewer workshop visits and forecasts costs with a monthly set-aside. Flags overdue safety and legal items, missing records and odometer typos. Includes an offline stdlib Python planner.
---
name: car-maintenance-service-schedule-planner
description: Plans car maintenance from your service schedule and service history - estimates how far you drive from odometer readings, works out what is overdue, due soon or next due by distance or time (whichever comes first), bundles items into fewer workshop visits and forecasts costs with a monthly set-aside. Flags overdue safety and legal items, missing records and odometer typos. Includes an offline stdlib Python planner.
---
# Car Maintenance Service Schedule Planner
You help car owners keep up with maintenance without overpaying. People lose track because every item runs on its own clock: oil every 15,000 km or 12 months, brake fluid every 2 years regardless of distance, the roadworthiness test every year, a timing belt at 120,000 km or 8 years. Something is always about to be due, receipts are scattered and the expensive items arrive as a surprise. You turn the owner's schedule and their service history into a clear list of what is due, a short visit plan that groups items into as few workshop trips as possible, and a cost forecast they can budget for.
You plan from the intervals the owner gives you (from the owner's manual, the service booklet or the dealer). You do not diagnose faults or decide whether a part is worn; tell the user to follow the workshop's findings and the manufacturer's schedule, and to get warning lights, noises, brake or steering problems checked straight away.
## Files in this skill
- `scripts/car_service_plan.py` - reads a car JSON (vehicle, current odometer, schedule items with km and/or month intervals, severe-service intervals, first-test delay, cost, safety/legal flags, name aliases) and a service log CSV (date, odometer, items separated by ";", cost, shop); estimates yearly distance from the readings, finds the last time each item was done, computes the next due date by time and by projected distance, statuses OVERDUE / DUE SOON / OK, bundled workshop visits over the horizon and costs per quarter; reports findings CS01-CS09 (Python 3.8+ standard library only)
- `references/intervals-and-severe-service.md` - how to read the manufacturer's schedule, time vs distance, severe-service conditions, typical item notes and what never to postpone
- `references/checks-and-codes.md` - every finding code, its severity and what to do, plus the input fields
- `templates/service-plan.md` - the one-page plan and the checklist to take to the workshop
- `examples/car.json` - a 2019 petrol estate car at 83,400 km with 11 schedule items (illustrative intervals and prices - replace with your manual's)
- `examples/service-log.csv` - seven years of service history with combined visits, an alias ("Oil change"), an item not in the schedule and an odometer typo
- `examples/service-plan.md` - the real planner output on the example
## Workflow
### 1. Collect the schedule and the history
Ask for the make, model, engine and first registration date, today's odometer reading, and the maintenance schedule from the owner's manual or service booklet (item, distance interval, time interval, severe-service intervals if they drive mostly short trips, tow, or drive in dust or extreme heat or cold). Ask for the service history: invoices, stamps in the service book or the dealer's printout, with date, odometer and what was done. Add rough prices from their workshop's last invoices. Write them into a car JSON like `examples/car.json` and a log like `examples/service-log.csv`.
### 2. Run the planner
```
python3 scripts/car_service_plan.py examples/car.json examples/service-log.csv
python3 scripts/car_service_plan.py car.json log.csv --odometer 84100 --today 2026-11-20
python3 scripts/car_service_plan.py car.json log.csv --severe --horizon 36
python3 scripts/car_service_plan.py car.json log.csv --json
python3 scripts/car_service_plan.py --self-test
```
Exit 0 = no overdue safety or legal item, 1 = a safety/legal item is overdue (or the log has a high finding), 2 = bad input. Without a shell, for each item take the last date and odometer, add both intervals, convert the distance to a date with the yearly distance, and take the earlier one.
### 3. Read the result
- High first: CS01 high (overdue brakes, brake fluid, tyres, legal test) - drive carefully and book now; CS04 high (dates in the future, readings above today's) - fix the log.
- Medium: other overdue items, items due soon, items with no record (CS03 - counted from new, so check the service book before paying for them) and odometer readings that go backwards.
- Low/info: big-ticket items inside the plan (CS08), items done well before their interval (CS05), log entries not in the schedule, severe intervals in use.
- If the yearly distance looks wrong, check the readings; use `--odometer` with today's reading.
### 4. Plan the visits
Use the visit table: book visit 1 for everything overdue plus what is due within the bundle window, and ask the workshop to quote the whole list in one go. Move a cheap item a few weeks forward if it saves a trip, but never push a safety or legal item later. Fill `templates/service-plan.md` with the visits, the checklist for the workshop and the monthly set-aside.
### 5. Deliver
Return the overdue and due-soon list in plain words, the visit plan with dates and estimated costs, the 12- and 24-month cost forecast and the monthly amount to put aside, plus questions to check (records for items with no history, the exact interval for their engine, warranty conditions that require dealer servicing).
## Example result
For the 2019 Octavia at 83,400 km (about 11,000 km a year) four items are overdue: brake fluid (126 days, safety), the inspection service and air filter (by time) and the tyre rotation (3,300 km), and the roadworthiness test is due on 20 Nov 2026 - one visit now covers all five for about 410 EUR. There is no record of the timing belt, so it is counted from new and due by age in November 2027 (about 680 EUR), and new tyres are projected for June 2027 (about 560 EUR). Maintenance over the next 24 months comes to about 2,445 EUR, so set aside about 102 EUR a month. The 4,790 km reading in Sept 2023 is a typo and is ignored.
## Rules
- Use the manufacturer's intervals for the exact engine; the example values are illustrative only.
- Whichever comes first: an item is due at the earlier of its distance and its time limit, even for a car that is hardly driven.
- Never suggest postponing brakes, tyres, steering, warning lights or a legal test to save money or a trip.
- An item with no record is unknown, not done: say so and suggest checking the service book or asking the workshop.
- Costs are estimates; tell the user to get a quote, and mention warranty conditions before suggesting an independent garage.
FILE:references/intervals-and-severe-service.md
# Intervals and severe service
## Where the intervals come from
- The owner's manual or service booklet for your exact model, engine and model year. The same model can have different schedules for petrol, diesel and hybrid engines, and for "fixed" vs "flexible/long-life" service regimes.
- Many cars show the next oil service on the dashboard (service indicator). If it disagrees with the booklet, ask the dealer which regime the car is set to.
- Keep warranty conditions in mind: some manufacturers require servicing to their schedule with genuine-spec parts; independent garages are usually fine if they follow the schedule and keep invoices.
## Whichever comes first
Every item with both a distance and a time interval is due at the EARLIER of the two. Low-mileage cars hit the time limit (oil ages, brake fluid absorbs water, rubber belts and tyres age); high-mileage cars hit the distance limit. The planner projects the distance limit to a date with your yearly distance.
## Severe service
Most manuals list shorter intervals when the car is used mostly for:
- short trips (under about 10 km, engine rarely fully warm), especially in cold weather;
- stop-and-go city traffic, taxi or delivery use, long idling;
- towing a trailer or caravan, roof loads, mountain driving;
- dusty or sandy roads, very hot or very cold climates.
If any of these describe most of your driving, use `--severe` (or `"severe": true`) so items with `severe_km` / `severe_months` use the shorter interval.
## Item notes (typical, check your manual)
| Item | Typical basis | Notes |
|---|---|---|
| Engine oil and filter | distance and time | the most important routine item; use the oil spec in the manual |
| Inspection service | distance and time | the bigger service; usually includes many checks |
| Brake fluid | time only (often 2 years) | absorbs water; never postpone |
| Brake inspection / pads / discs | distance and time | replace on wear, not on a fixed interval |
| Coolant | time or "lifetime" | check level and freeze protection each winter |
| Air filter, cabin filter | distance and time | shorter in dusty or polluted areas |
| Spark plugs (petrol) | distance and time | long intervals on modern engines |
| Timing belt (if fitted) | distance and time | failure can destroy the engine; often changed with the water pump; chains usually have no interval |
| Tyres | wear and age | check tread depth monthly; many makers suggest replacing at 6-10 years regardless of tread |
| Tyre rotation | distance | if your manual recommends it (not for different-size front/rear tyres) |
| Roadworthiness test | time (legal) | first test after a few years (e.g. 3 or 4), then every 1-2 years depending on the country |
| 12 V battery | condition | test before winter once it is 4-5 years old |
## Legal tests
Rules differ by country (MOT in the UK, TUV/HU in Germany, CT in France, TUVTURK in Turkey, state inspections in the US). Enter your country's first-test delay as `first_months` and the repeat interval as `months`, and mark the item `"legal": true`. Many schemes let you test a little early without losing the date; check yours before bundling it into an earlier visit.
## What never to postpone
Brakes and brake fluid, tyres (tread, damage, age), steering and suspension faults, warning lights (oil pressure, brakes, engine temperature), and the legal test. The planner marks these with `safety` / `legal` and treats them as high when overdue.
## Getting the inputs
- Odometer and date of every visit: invoices, the service book stamps, the dealer's digital service record, the legal test certificates (they record the odometer).
- Prices: your last invoices or a quote; add 10-20% for parts that are often replaced at the same time (wipers, bulbs, brake pads).
FILE:references/checks-and-codes.md
# Checks and finding codes
| Code | Severity | Check | What to do |
|---|---|---|---|
| CS01 | high: safety or legal item overdue; medium: other item overdue | due date or due distance passed | book it; for safety items drive carefully until done |
| CS02 | medium | due within `due_soon.days` or `due_soon.km` | book it with the next visit |
| CS03 | medium | no record of the item in the log: counted from first registration | check the service book / receipts / dealer record before paying for it |
| CS04 | high: date in the future, reading above the current odometer; medium: reading lower than an earlier one, unreadable odometer | log consistency | fix the typo; the reading is ignored for distance and due calculations |
| CS05 | info | last done at under 60% of both intervals | fine if intended (severe use, warranty); otherwise money spent early |
| CS06 | info | severe-service intervals in use | check the conditions still apply |
| CS07 | info | log item not in your schedule (repairs, extras) | kept for history; add aliases if it is a schedule item under another name |
| CS08 | low | item costing 400 or more falls inside the plan horizon | budget for it; get a quote early |
| CS09 | medium | not enough odometer readings (a reading at least 60 days back): default yearly distance used | add readings (test certificates, fuel app, photos of the dashboard) |
## Verdict
- **Overdue including safety/legal**: book now.
- **Overdue**: book soon.
- **Due soon** / **up to date**.
## How the numbers are worked out
- Yearly distance: from the latest log reading at least a year before today to today's reading (the earliest reading if none is that old).
- Next due: last done date + months, and last done odometer + km, converted to a date with your daily distance; the earlier wins. An item with no record counts from first registration at 0 km (`first_months` replaces `months` for the first time, e.g. a first legal test after 36 months).
- Horizon: each item repeats from the date it is assumed done (its due date, or today if overdue).
- Visits: items due within `bundle.days` and `bundle.km` of the first item of a visit join that visit (pulled forward, never pushed later).
- Costs: each occurrence's `cost` added to its quarter; monthly set-aside = horizon total / horizon months.
## Car JSON keys
`vehicle{name, first_registered, unit}, odometer{reading, date}, driving{default_per_year, severe, severe_reasons}, currency, horizon_months, due_soon{km, days}, bundle{km, days}, schedule[{item, km, months, severe_km, severe_months, first_months, cost, safety, legal, aliases[]}]`.
## Service log CSV
`date, odometer, item, cost, shop, notes` - several items in one visit separated by `;`. Names are matched to schedule items and aliases case-insensitively. The cost column is for your records; the forecast uses the schedule prices.
FILE:templates/service-plan.md
# Car service plan: <car, registration>
**Date:** <YYYY-MM-DD> **Odometer:** <83,400 km> **Yearly distance:** <about 11,000 km> **Schedule:** <owner's manual, normal / severe service>
## Do now
| Item | Why | Est. cost |
|---|---|---|
| <Brake fluid> | <overdue by 4 months (2-year interval) - safety> | <85> |
| <Roadworthiness test> | <due 2026-11-20 - legal> | <60> |
## Next visits
| When (book by) | At about | Items | Est. cost |
|---|---|---|---|
| <2027-02-14> | <86,500 km> | <oil and filter, brake inspection> | <180> |
## Budget
- Next 12 months: <1,220> <EUR>
- Next 24 months: <2,445> <EUR> -> set aside <102> per month
- Big items ahead: <timing belt kit and water pump, Nov 2027, about 680>
## To check
- [ ] <no record of the timing belt - check the service book / ask the dealer>
- [ ] <exact brake fluid interval for this engine>
- [ ] <warranty: does it require dealer servicing?>
## Checklist for the workshop
- Car: <make, model, engine, VIN if asked>, odometer <...>
- Please do: <list from "Do now">
- Please check and quote before replacing: <brake pads/discs, tyres, wipers, battery>
- Please stamp the service book / send a digital record and note the odometer on the invoice.
*Intervals from <source>; costs are estimates. Get anything unusual (warning lights, noises, pulling, vibration) checked straight away.*
FILE:examples/car.json
{
"vehicle": {"name": "2019 Skoda Octavia Estate 1.5 TSI (petrol)", "first_registered": "2019-11-14", "unit": "km"},
"odometer": {"reading": 83400, "date": "2026-11-04"},
"driving": {"default_per_year": 12000, "severe": false, "severe_reasons": ["mostly short trips in winter", "occasional caravan towing"]},
"currency": "EUR",
"horizon_months": 24,
"due_soon": {"km": 1500, "days": 45},
"bundle": {"km": 2500, "days": 60},
"schedule": [
{"item": "Engine oil and filter", "km": 15000, "months": 12, "severe_km": 7500, "severe_months": 6, "cost": 140, "aliases": ["oil service", "oil change"]},
{"item": "Inspection service", "km": 30000, "months": 24, "severe_km": 20000, "cost": 190, "aliases": ["inspection"]},
{"item": "Brake inspection", "km": 20000, "months": 12, "cost": 40, "safety": true},
{"item": "Brake fluid", "months": 24, "cost": 85, "safety": true},
{"item": "Air filter", "km": 60000, "months": 48, "severe_km": 30000, "cost": 45},
{"item": "Cabin filter", "km": 30000, "months": 24, "cost": 40, "aliases": ["pollen filter"]},
{"item": "Spark plugs", "km": 60000, "months": 72, "cost": 160},
{"item": "Tyre rotation", "km": 10000, "cost": 30},
{"item": "Tyres (set of 4)", "km": 45000, "months": 72, "cost": 560, "safety": true},
{"item": "Timing belt kit and water pump", "km": 120000, "months": 96, "cost": 680},
{"item": "Roadworthiness test", "months": 12, "first_months": 36, "cost": 60, "legal": true, "aliases": ["mot", "periodic inspection"]}
]
}
FILE:examples/service-log.csv
date,odometer,item,cost,shop,notes
2020-11-20,11500,Oil service; Brake inspection,165,Main dealer,first service
2021-11-05,22800,Oil service; Brake inspection; Cabin filter,210,Main dealer,
2022-05-28,33900,Oil service; Inspection service; Brake fluid; Air filter,395,Main dealer,
2022-11-08,38000,Roadworthiness test,55,Test centre,passed
2023-06-15,45150,Oil service; Brake inspection; Tyre rotation; Tyres (set of 4),735,QuickFit,new tyres 205/55 R16
2023-09-02,4790,Windscreen chip repair,60,GlassFix,
2023-11-13,49000,Roadworthiness test,55,Test centre,passed
2024-07-01,56900,Oil service; Inspection service; Brake fluid; Spark plugs; Brake inspection,520,Main dealer,
2024-11-12,59800,Roadworthiness test,58,Test centre,advisory: rear wiper
2025-08-20,70100,Oil service; Brake inspection; Cabin filter; Tyre rotation,240,Corner Garage,
2025-11-20,73000,Roadworthiness test,60,Test centre,passed
2026-02-14,75300,Oil change; Brake inspection,150,Corner Garage,dash light came on
FILE:examples/service-plan.md
# Service plan: 2019 Skoda Octavia Estate 1.5 TSI (petrol)
Today 2026-11-04, odometer 83,400 km. You drive about 11,008 km/year (2025-08-20 70,100 -> 2026-11-04 83,400). Normal-service intervals, plan horizon 24 months. Source: examples/service-log.csv.
**Verdict: 4 item(s) overdue, including safety/legal items - book a visit now**
Next 12 months: about 1,220.00 EUR; next 24 months: 2,445.00 EUR (set aside about 101.88 EUR per month). Findings: 1 high, 6 medium, 2 low, 3 info.
## Items
| Item | Interval | Last done | Due by date | Due at km | Next (projected) | Trigger | Status | Est. cost |
|---|---|---|---|---|---|---|---|---|
| Inspection service | 30,000 km / 24 months | 2024-07-01 at 56,900 | 2026-07-01 | 86,900 | 2026-11-04 | time | **OVERDUE** | 190.00 |
| Brake fluid [safety] | 24 months | 2024-07-01 at 56,900 | 2026-07-01 | - | 2026-11-04 | time | **OVERDUE** | 85.00 |
| Air filter | 60,000 km / 48 months | 2022-05-28 at 33,900 | 2026-05-28 | 93,900 | 2026-11-04 | time | **OVERDUE** | 45.00 |
| Tyre rotation | 10,000 km | 2025-08-20 at 70,100 | - | 80,100 | 2026-11-04 | distance | **OVERDUE** | 30.00 |
| Roadworthiness test [legal] | 12 months | 2025-11-20 at 73,000 | 2026-11-20 | - | 2026-11-20 | time | **DUE SOON** | 60.00 |
| Engine oil and filter | 15,000 km / 12 months | 2026-02-14 at 75,300 | 2027-02-14 | 90,300 | 2027-02-14 | time | OK | 140.00 |
| Brake inspection [safety] | 20,000 km / 12 months | 2026-02-14 at 75,300 | 2027-02-14 | 95,300 | 2027-02-14 | time | OK | 40.00 |
| Tyres (set of 4) [safety] | 45,000 km / 72 months | 2023-06-15 at 45,150 | 2029-06-15 | 90,150 | 2027-06-15 | distance | OK | 560.00 |
| Cabin filter | 30,000 km / 24 months | 2025-08-20 at 70,100 | 2027-08-20 | 100,100 | 2027-08-20 | time | OK | 40.00 |
| Timing belt kit and water pump | 120,000 km / 96 months | no record (counted from new, 2019-11-14) | 2027-11-14 | 120,000 | 2027-11-14 | time | OK | 680.00 |
| Spark plugs | 60,000 km / 72 months | 2024-07-01 at 56,900 | 2030-07-01 | 116,900 | 2029-11-18 | distance | OK | 160.00 |
## Workshop visits (items within 60 days / 2,500 km are bundled)
| # | Book by | At about | Items | Est. cost |
|---|---|---|---|---|
| 1 | 2026-11-04 (now) | 83,400 km | Air filter (overdue), Brake fluid (overdue), Inspection service (overdue), Tyre rotation (overdue), Roadworthiness test | 410.00 |
| 2 | 2027-02-14 | 86,476 km | Brake inspection, Engine oil and filter | 180.00 |
| 3 | 2027-06-15 | 90,125 km | Tyres (set of 4) | 560.00 |
| 4 | 2027-08-20 | 92,116 km | Cabin filter, Tyre rotation | 70.00 |
| 5 | 2027-11-14 | 94,710 km | Timing belt kit and water pump, Roadworthiness test | 740.00 |
| 6 | 2028-02-14 | 97,484 km | Brake inspection, Engine oil and filter | 180.00 |
| 7 | 2028-08-27 | 103,365 km | Tyre rotation | 30.00 |
| 8 | 2028-11-04 | 105,446 km | Brake fluid, Inspection service | 275.00 |
## Cost forecast by quarter
| Quarter | Est. cost |
|---|---|
| 2026-Q4 | 410.00 |
| 2027-Q1 | 180.00 |
| 2027-Q2 | 560.00 |
| 2027-Q3 | 40.00 |
| 2027-Q4 | 770.00 |
| 2028-Q1 | 180.00 |
| 2028-Q3 | 30.00 |
| 2028-Q4 | 275.00 |
| **Total 24 months** | **2,445.00** |
## Findings
- **HIGH CS01** Brake fluid is overdue by 126 days (safety item) - book it now
- **MEDIUM CS01** Air filter is overdue by 160 days - book it now
- **MEDIUM CS01** Inspection service is overdue by 126 days - book it now
- **MEDIUM CS01** Tyre rotation is overdue by 3,300 km - book it now
- **MEDIUM CS02** Roadworthiness test is due soon: 2026-11-20
- **MEDIUM CS03** Timing belt kit and water pump: no record in your log - counted from new; check the service book or receipts
- **MEDIUM CS04** row 7: odometer 4,790 on 2023-09-02 is lower than 45,150 on 2023-06-15 - typo or wrong car? reading ignored
- **LOW CS08** Timing belt kit and water pump (about 680.00) falls in the plan on 2027-11-14 - budget for it now
- **LOW CS08** Tyres (set of 4) (about 560.00) falls in the plan on 2027-06-15 - budget for it now
- **INFO CS05** Brake inspection was last done after 5,200 km / 6 months (under 60% of the interval) - fine for severe use, otherwise money spent early
- **INFO CS05** Engine oil and filter was last done after 5,200 km / 6 months (under 60% of the interval) - fine for severe use, otherwise money spent early
- **INFO CS07** row 7: 'Windscreen chip repair' is not in your schedule - kept in the history only
Intervals and prices come from your car file: check them against the owner's manual or service booklet for your exact engine, and follow your workshop if it finds wear earlier. Costs are estimates; tyres and brakes also depend on wear, not only on distance.
FILE:scripts/car_service_plan.py
#!/usr/bin/env python3
"""Car maintenance service schedule planner (stdlib only, Python 3.8+).
Reads your car's maintenance schedule (items with distance and/or time intervals, from the
owner's manual or service booklet) and your service history (CSV), estimates how far you drive
from the odometer readings in the log, and works out for every item when it was last done, when
it is next due (distance or time, whichever comes first), what is overdue or due soon, which
items can share one workshop visit, and what maintenance will cost over the next months.
Usage:
car_service_plan.py CAR.json SERVICE_LOG.csv [--odometer N] [--today YYYY-MM-DD] [--severe]
[--horizon MONTHS] [--json]
car_service_plan.py --self-test
Exit: 0 = nothing overdue on a safety/legal item, 1 = a safety or legal item is overdue (or the
log has a high finding), 2 = bad input.
"""
import argparse, calendar, csv, datetime, io, json, sys
SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
CAR_KEYS = {"vehicle", "odometer", "driving", "currency", "horizon_months", "due_soon", "bundle", "schedule"}
class InputError(Exception):
pass
def pdate(s, what):
try:
return datetime.date.fromisoformat(str(s).strip())
except ValueError:
raise InputError("%s must be YYYY-MM-DD, got %r" % (what, s))
def add_months(d, m):
y, mo = divmod(d.month - 1 + int(m), 12)
y += d.year
mo += 1
return datetime.date(y, mo, min(d.day, calendar.monthrange(y, mo)[1]))
def num(x, what):
try:
return float(str(x).replace(",", "").replace(" ", ""))
except ValueError:
raise InputError("%s is not a number: %r" % (what, x))
def fk(x):
return "{:,.0f}".format(x)
def fm(x):
return "{:,.2f}".format(x)
# ---------------------------------------------------------------- load
def load_car(cfg, odometer=None, today=None, severe=None, horizon=None):
if not isinstance(cfg, dict):
raise InputError("car file must be a JSON object")
bad = set(cfg) - CAR_KEYS
if bad:
raise InputError("unknown key(s) in car file: %s" % ", ".join(sorted(bad)))
v = cfg.get("vehicle") or {}
odo = cfg.get("odometer") or {}
drv = cfg.get("driving") or {}
c = {"name": v.get("name", "Car"), "unit": v.get("unit", "km"),
"first": pdate(v["first_registered"], "vehicle.first_registered") if v.get("first_registered") else None,
"today": pdate(today or odo.get("date") or datetime.date.today().isoformat(), "today"),
"odo": num(odometer if odometer is not None else odo.get("reading"), "odometer reading") if (odometer is not None or odo.get("reading") is not None) else None,
"default_per_year": num(drv.get("default_per_year", 12000), "driving.default_per_year"),
"severe": bool(drv.get("severe", False)) if severe is None else severe,
"severe_reasons": drv.get("severe_reasons", []),
"currency": cfg.get("currency", ""),
"horizon": int(horizon or cfg.get("horizon_months", 24)),
"soon_km": num((cfg.get("due_soon") or {}).get("km", 1500), "due_soon.km"),
"soon_days": int((cfg.get("due_soon") or {}).get("days", 45)),
"bundle_km": num((cfg.get("bundle") or {}).get("km", 2500), "bundle.km"),
"bundle_days": int((cfg.get("bundle") or {}).get("days", 60)),
"items": []}
if c["odo"] is None:
raise InputError("give the current odometer reading (odometer.reading or --odometer)")
for i, it in enumerate(cfg.get("schedule") or [], 1):
name = it.get("item")
if not name:
raise InputError("schedule item %d has no 'item' name" % i)
km = it.get("severe_km") if c["severe"] and it.get("severe_km") else it.get("km")
mo = it.get("severe_months") if c["severe"] and it.get("severe_months") else it.get("months")
if not km and not mo:
raise InputError("schedule item '%s' needs km and/or months" % name)
c["items"].append({"item": name, "km": num(km, name + " km") if km else None, "months": int(mo) if mo else None,
"first_months": int(it["first_months"]) if it.get("first_months") else None,
"cost": num(it.get("cost", 0), name + " cost"), "safety": bool(it.get("safety")), "legal": bool(it.get("legal")),
"severe_used": bool(c["severe"] and (it.get("severe_km") or it.get("severe_months"))),
"aliases": [a.lower().strip() for a in it.get("aliases", [])] + [name.lower().strip()]})
if not c["items"]:
raise InputError("car file needs a non-empty 'schedule'")
return c
def load_log(c, text, F):
rows = list(csv.DictReader(io.StringIO(text)))
if rows and not {"date", "odometer", "item"} <= set(rows[0]):
raise InputError("service log needs columns date, odometer, item (has %s)" % ", ".join(rows[0]))
entries, readings = [], []
for n, r in enumerate(rows, 2):
try:
d = pdate(r.get("date"), "date (row %d)" % n)
except InputError as e:
F.append(("high", "CS04", "row %d: %s - row ignored" % (n, e))); continue
if d > c["today"]:
F.append(("high", "CS04", "row %d: date %s is in the future - row ignored" % (n, d))); continue
odo = None
if (r.get("odometer") or "").strip():
try:
odo = num(r["odometer"], "odometer (row %d)" % n)
except InputError as e:
F.append(("medium", "CS04", "%s - distance ignored for this row" % e))
items = [x.strip() for x in (r.get("item") or "").split(";") if x.strip()]
entries.append({"row": n, "date": d, "odo": odo, "items": items, "cost": r.get("cost", ""), "shop": r.get("shop", "")})
entries.sort(key=lambda e: (e["date"], e["row"]))
last = None
for e in entries:
if e["odo"] is None:
continue
if e["odo"] > c["odo"]:
F.append(("high", "CS04", "row %d: odometer %s on %s is higher than the current reading %s - typo? reading ignored" % (e["row"], fk(e["odo"]), e["date"], fk(c["odo"]))))
e["odo"] = None; continue
if last and e["odo"] < last["odo"]:
F.append(("medium", "CS04", "row %d: odometer %s on %s is lower than %s on %s - typo or wrong car? reading ignored" % (e["row"], fk(e["odo"]), e["date"], fk(last["odo"]), last["date"])))
e["odo"] = None; continue
last = e
readings.append((e["date"], e["odo"]))
readings.append((c["today"], c["odo"]))
return entries, readings
def distance_rate(c, readings, F):
"""km per day from the latest reading at least a year before today (else the earliest)."""
end_d, end_o = readings[-1]
older = [r for r in readings[:-1] if (end_d - r[0]).days >= 365]
start = older[-1] if older else (readings[0] if len(readings) > 1 else None)
if not start or (end_d - start[0]).days < 60 or end_o <= start[1]:
F.append(("medium", "CS09", "not enough odometer readings to estimate your yearly distance - using the default %s %s/year" % (fk(c["default_per_year"]), c["unit"])))
return c["default_per_year"] / 365.0, "default"
rate = (end_o - start[1]) / float((end_d - start[0]).days)
return rate, "%s %s -> %s %s" % (start[0], fk(start[1]), end_d, fk(end_o))
# ---------------------------------------------------------------- plan
def plan(c, log_text):
F = []
entries, readings = load_log(c, log_text, F)
rate, basis = distance_rate(c, readings, F)
known = {a: it for it in c["items"] for a in it["aliases"]}
done = {it["item"]: [] for it in c["items"]}
for e in entries:
for name in e["items"]:
it = known.get(name.lower())
if not it:
F.append(("info", "CS07", "row %d: '%s' is not in your schedule - kept in the history only" % (e["row"], name)))
continue
done[it["item"]].append(e)
def date_at_km(km):
return c["today"] + datetime.timedelta(days=max(0.0, (km - c["odo"]) / rate)) if rate > 0 else None
def km_at_date(d):
return c["odo"] + rate * (d - c["today"]).days
end = add_months(c["today"], c["horizon"])
rows, occ = [], []
for it in c["items"]:
hist = done[it["item"]]
last = hist[-1] if hist else None
if last:
base_d, base_km, base_note = last["date"], last["odo"], "%s at %s" % (last["date"], fk(last["odo"]) if last["odo"] is not None else "?")
else:
if not c["first"]:
raise InputError("no record of '%s' and no vehicle.first_registered to count from" % it["item"])
base_d, base_km, base_note = c["first"], 0.0, "no record (counted from new, %s)" % c["first"]
F.append(("medium", "CS03", "%s: no record in your log - counted from new; check the service book or receipts" % it["item"]))
months = it["months"]
if not last and it["first_months"]:
months = it["first_months"]
due_d = add_months(base_d, months) if months else None
due_km = base_km + it["km"] if (it["km"] and base_km is not None) else None
km_d = date_at_km(due_km) if due_km is not None else None
cands = [(x, w) for x, w in ((due_d, "time"), (km_d, "distance")) if x]
if not cands:
due_date, trigger = c["today"], "unknown"
else:
due_date, trigger = min(cands)
overdue = (due_d is not None and due_d < c["today"]) or (due_km is not None and due_km < c["odo"])
soon = not overdue and ((due_d is not None and (due_d - c["today"]).days <= c["soon_days"]) or
(due_km is not None and due_km - c["odo"] <= c["soon_km"]))
status = "OVERDUE" if overdue else ("DUE SOON" if soon else "OK")
if overdue:
late = []
if due_d is not None and due_d < c["today"]:
late.append("%d days" % (c["today"] - due_d).days)
if due_km is not None and due_km < c["odo"]:
late.append("%s %s" % (fk(c["odo"] - due_km), c["unit"]))
sev = "high" if (it["safety"] or it["legal"]) else "medium"
F.append((sev, "CS01", "%s is overdue by %s%s - book it now" % (it["item"], " and ".join(late), " (safety item)" if it["safety"] else (" (legal requirement)" if it["legal"] else ""))))
elif soon:
F.append(("medium", "CS02", "%s is due soon: %s%s" % (it["item"], due_d if due_d and (due_d - c["today"]).days <= c["soon_days"] else "", (" / at %s %s" % (fk(due_km), c["unit"])) if due_km is not None else "")))
if len(hist) >= 2 and hist[-2]["odo"] is not None and hist[-1]["odo"] is not None:
a, b = hist[-2], hist[-1]
fr = []
if it["km"]:
fr.append((b["odo"] - a["odo"]) / it["km"])
if it["months"]:
fr.append(((b["date"] - a["date"]).days / 30.44) / it["months"])
if fr and max(fr) < 0.6:
F.append(("info", "CS05", "%s was last done after %s %s / %d months (under 60%% of the interval) - fine for severe use, otherwise money spent early" % (it["item"], fk(b["odo"] - a["odo"]), c["unit"], round((b["date"] - a["date"]).days / 30.44))))
rows.append({"item": it["item"], "last": base_note, "interval": " / ".join(x for x in [("%s %s" % (fk(it["km"]), c["unit"])) if it["km"] else "", ("%d months" % months) if months else ""] if x),
"due_date": due_d, "due_km": due_km, "next": max(due_date, c["today"]), "trigger": trigger, "status": status, "cost": it["cost"],
"safety": it["safety"] or it["legal"], "flag": "legal" if it["legal"] else ("safety" if it["safety"] else ""), "severe": it["severe_used"]})
# occurrences in the horizon (each one assumed done on its due date)
d, k, first = max(due_date, c["today"]), due_km, True
guard = 0
while d <= end and guard < 50:
guard += 1
occ.append({"item": it["item"], "date": d, "km": km_at_date(d), "cost": it["cost"], "overdue": first and overdue})
first = False
nd = add_months(d, it["months"]) if it["months"] else None
nk = km_at_date(d) + it["km"] if it["km"] else None
cands = [x for x in (nd, date_at_km(nk) if nk is not None else None) if x]
if not cands:
break
d = min(cands)
if (it["cost"] >= 400) and any(o["item"] == it["item"] for o in occ):
o = next(o for o in occ if o["item"] == it["item"])
F.append(("low", "CS08", "%s (about %s) falls in the plan on %s - budget for it now" % (it["item"], fm(it["cost"]), o["date"])))
if c["severe"]:
F.append(("info", "CS06", "severe-service intervals used%s" % ((" (" + ", ".join(c["severe_reasons"]) + ")") if c["severe_reasons"] else "")))
# visits: group occurrences within the bundle window
occ.sort(key=lambda o: (o["date"], o["item"]))
visits = []
for o in occ:
v = visits[-1] if visits else None
if v and (o["date"] - v["date"]).days <= c["bundle_days"] and o["km"] - v["km"] <= c["bundle_km"]:
v["items"].append(o); continue
visits.append({"date": o["date"], "km": o["km"], "items": [o]})
# costs by quarter
quarters = {}
for o in occ:
q = "%d-Q%d" % (o["date"].year, (o["date"].month - 1) // 3 + 1)
quarters[q] = quarters.get(q, 0.0) + o["cost"]
y1 = sum(o["cost"] for o in occ if o["date"] < add_months(c["today"], 12))
total = sum(o["cost"] for o in occ)
F.sort(key=lambda f: (SEV[f[0]], f[1], f[2]))
return {"car": c, "rate": rate, "basis": basis, "rows": rows, "occ": occ, "visits": visits, "quarters": quarters,
"next12": y1, "total": total, "findings": F, "entries": entries}
# ---------------------------------------------------------------- report
def report(res, title):
c, F = res["car"], res["findings"]
cnt = {s: sum(1 for f in F if f[0] == s) for s in SEV}
cur = (" " + c["currency"]) if c["currency"] else ""
ov = [r for r in res["rows"] if r["status"] == "OVERDUE"]
soon = [r for r in res["rows"] if r["status"] == "DUE SOON"]
L = ["# Service plan: %s" % c["name"], ""]
L.append("Today %s, odometer %s %s. You drive about %s %s/year (%s). %s intervals, plan horizon %d months. Source: %s." % (
c["today"], fk(c["odo"]), c["unit"], fk(res["rate"] * 365), c["unit"], res["basis"], "Severe-service" if c["severe"] else "Normal-service", c["horizon"], title))
L.append("")
if any(r["safety"] for r in ov):
L.append("**Verdict: %d item(s) overdue, including safety/legal items - book a visit now**" % len(ov))
elif ov:
L.append("**Verdict: %d item(s) overdue - book a visit soon**" % len(ov))
elif soon:
L.append("**Verdict: nothing overdue, %d item(s) due soon**" % len(soon))
else:
L.append("**Verdict: up to date**")
L.append("")
L.append("Next 12 months: about %s%s; next %d months: %s%s (set aside about %s%s per month). Findings: %d high, %d medium, %d low, %d info." % (
fm(res["next12"]), cur, c["horizon"], fm(res["total"]), cur, fm(res["total"] / c["horizon"]), cur, cnt["high"], cnt["medium"], cnt["low"], cnt["info"]))
L += ["", "## Items", "", "| Item | Interval | Last done | Due by date | Due at %s | Next (projected) | Trigger | Status | Est. cost |" % c["unit"], "|---|---|---|---|---|---|---|---|---|"]
order = {"OVERDUE": 0, "DUE SOON": 1, "OK": 2}
for r in sorted(res["rows"], key=lambda r: (order[r["status"]], r["next"])):
L.append("| %s%s | %s | %s | %s | %s | %s | %s | %s | %s |" % (
r["item"], (" [%s]" % r["flag"]) if r["flag"] else "", r["interval"] + (" (severe)" if r["severe"] else ""), r["last"],
r["due_date"] or "-", fk(r["due_km"]) if r["due_km"] is not None else "-", r["next"], r["trigger"],
"**%s**" % r["status"] if r["status"] != "OK" else "OK", fm(r["cost"])))
L += ["", "## Workshop visits (items within %d days / %s %s are bundled)" % (c["bundle_days"], fk(c["bundle_km"]), c["unit"]), "",
"| # | Book by | At about | Items | Est. cost |", "|---|---|---|---|---|"]
for i, v in enumerate(res["visits"], 1):
L.append("| %d | %s%s | %s %s | %s | %s |" % (i, v["date"], " (now)" if v["date"] == c["today"] else "", fk(v["km"]), c["unit"],
", ".join(o["item"] + (" (overdue)" if o["overdue"] else "") for o in v["items"]), fm(sum(o["cost"] for o in v["items"]))))
L += ["", "## Cost forecast by quarter", "", "| Quarter | Est. cost |", "|---|---|"]
for q in sorted(res["quarters"]):
L.append("| %s | %s |" % (q, fm(res["quarters"][q])))
L.append("| **Total %d months** | **%s** |" % (c["horizon"], fm(res["total"])))
L += ["", "## Findings", ""]
L += ["- **%s %s** %s" % (s.upper(), code, m) for s, code, m in F] or ["- none"]
L += ["", "Intervals and prices come from your car file: check them against the owner's manual or service booklet for your exact engine, and follow your workshop if it finds wear earlier. Costs are estimates; tyres and brakes also depend on wear, not only on distance."]
return "\n".join(L) + "\n"
def to_json(res):
def j(x):
if isinstance(x, datetime.date):
return x.isoformat()
if isinstance(x, float):
return round(x, 2)
if isinstance(x, dict):
return {k: j(v) for k, v in x.items()}
if isinstance(x, list):
return [j(v) for v in x]
return x
return j({"km_per_year": res["rate"] * 365, "items": res["rows"], "visits": [{"date": v["date"], "km": v["km"], "items": [o["item"] for o in v["items"]],
"cost": sum(o["cost"] for o in v["items"])} for v in res["visits"]], "quarters": res["quarters"], "next12": res["next12"], "total": res["total"],
"findings": [{"severity": s, "code": c, "message": m} for s, c, m in res["findings"]]})
# ---------------------------------------------------------------- self-test
def self_test():
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
def car(**kw):
cfg = {"vehicle": {"name": "T", "first_registered": "2020-01-01"}, "odometer": {"reading": 50000, "date": "2026-01-01"},
"schedule": [{"item": "Oil", "km": 15000, "months": 12, "severe_km": 7500, "severe_months": 6, "cost": 100, "aliases": ["oil change"]}]}
sched = kw.pop("schedule", None)
if sched:
cfg["schedule"] = sched
cfg.update(kw)
return cfg
H = "date,odometer,item,cost,shop\n"
def run(cfg, log, **kw):
return plan(load_car(cfg, **kw), H + log)
def row(res, name):
return next(r for r in res["rows"] if r["item"] == name)
def codes(res):
return [(f[0], f[1]) for f in res["findings"]]
log = "2025-01-01,40000,Oil,90,X\n"
r = run(car(), log)
t("rate from readings: 10000 km/year", abs(r["rate"] * 365 - 10000) < 1)
t("due by time first (12 months)", row(r, "Oil")["due_date"] == datetime.date(2026, 1, 1) and row(r, "Oil")["trigger"] == "time")
t("due today counts as due soon", row(r, "Oil")["status"] == "DUE SOON")
r = run(car(), "2025-06-01,40000,Oil,90,X\n")
t("distance triggers first when driving a lot", row(r, "Oil")["due_km"] == 55000 and row(r, "Oil")["trigger"] == "distance")
r = run(car(), "2024-06-01,30000,Oil,90,X\n")
t("overdue by time and distance -> CS01", row(r, "Oil")["status"] == "OVERDUE" and ("medium", "CS01") in codes(r))
sched = [{"item": "Brake fluid", "months": 24, "cost": 80, "safety": True}]
r = run(car(schedule=sched), "2023-06-01,30000,Brake fluid,80,X\n2025-01-01,40000,Other,0,X\n")
t("overdue safety item -> CS01 high", ("high", "CS01") in codes(r))
t("item not in schedule -> CS07", ("info", "CS07") in codes(r))
r = run(car(), "2025-01-01,40000,oil change,90,X\n")
t("aliases match case-insensitively", row(r, "Oil")["last"].startswith("2025-01-01"))
r = run(car(), "2025-01-01,40000,Wipers,20,X\n")
t("no record -> counted from new, CS03", ("medium", "CS03") in codes(r) and "no record" in row(r, "Oil")["last"])
r = run(car(), "2025-01-01,40000,Oil,90,X\n", severe=True)
t("severe intervals used", row(r, "Oil")["due_date"] == datetime.date(2025, 7, 1) and ("info", "CS06") in codes(r))
r = run(car(), "2024-01-01,30000,Oil,90,X\n2024-06-01,20000,Oil,90,X\n2025-01-01,40000,Oil,90,X\n")
t("odometer going down -> CS04, reading ignored", ("medium", "CS04") in codes(r))
r = run(car(), "2025-01-01,40000,Oil,90,X\n2027-01-01,60000,Oil,90,X\n")
t("future date -> CS04 high", ("high", "CS04") in codes(r))
r = run(car(), "2025-01-01,90000,Oil,90,X\n")
t("reading above current odometer -> CS04 high", ("high", "CS04") in codes(r))
r = run(car(), "")
t("no readings -> default rate + CS09", ("medium", "CS09") in codes(r) and abs(r["rate"] * 365 - 12000) < 1)
r = run(car(), "2025-01-01,40000,Oil,90,X\n2025-04-01,42000,Oil,90,X\n")
t("done early -> CS05", ("info", "CS05") in codes(r))
sched = [{"item": "Test", "months": 12, "first_months": 36, "cost": 50, "legal": True}]
r = run(car(vehicle={"first_registered": "2024-01-01"}, schedule=sched), "2025-01-01,40000,Other,0,X\n")
t("first_months for first test from new", row(r, "Test")["due_date"] == datetime.date(2027, 1, 1))
sched = [{"item": "A", "months": 12, "cost": 100}, {"item": "B", "months": 12, "cost": 50}]
r = run(car(schedule=sched), "2025-02-01,41000,A,0,X\n2025-03-15,42000,B,0,X\n")
t("items within the bundle window share a visit", len(r["visits"][0]["items"]) == 2)
t("horizon repeats yearly items", sum(1 for o in r["occ"] if o["item"] == "A") == 2)
t("12-month and total forecast", r["next12"] == 150 and r["total"] == 300)
sched = [{"item": "Belt", "km": 120000, "months": 96, "cost": 650}]
r = run(car(schedule=sched), "2025-01-01,40000,Other,0,X\n")
t("big-ticket item in horizon -> CS08", ("low", "CS08") in codes(r))
t("add_months clamps month end", add_months(datetime.date(2026, 1, 31), 1) == datetime.date(2026, 2, 28))
try:
load_car(car(schedule=[{"item": "X", "cost": 1}])); ok = False
except InputError:
ok = True
t("item without interval rejected", ok)
try:
load_car(dict(car(), colour="red")); ok = False
except InputError:
ok = True
t("unknown key rejected", ok)
r = run(car(), log)
t("report has verdict, visits and forecast", all(s in report(r, "t") for s in ("**Verdict:", "## Workshop visits", "## Cost forecast")))
for name, ok in tests:
print(("PASS " if ok else "FAIL ") + name)
passed = sum(ok for _, ok in tests)
print("%d/%d passed" % (passed, len(tests)))
return 0 if passed == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Plan car maintenance from a schedule and a service log.")
ap.add_argument("car", nargs="?")
ap.add_argument("log", nargs="?")
ap.add_argument("--odometer", type=float)
ap.add_argument("--today")
ap.add_argument("--severe", action="store_true", default=None, help="use severe-service intervals")
ap.add_argument("--horizon", type=int, help="months to plan (default from car file, else 24)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.car or not a.log:
ap.print_usage(sys.stderr)
return 2
try:
try:
cfg = json.load(open(a.car, encoding="utf-8"))
except json.JSONDecodeError as e:
raise InputError("car file is not valid JSON: %s" % e)
c = load_car(cfg, a.odometer, a.today, a.severe, a.horizon)
res = plan(c, open(a.log, encoding="utf-8-sig").read())
except (OSError, InputError) as e:
print("error: %s" % e, file=sys.stderr)
return 2
if a.json:
print(json.dumps(to_json(res), indent=2))
else:
sys.stdout.write(report(res, a.log))
return 1 if any(f[0] == "high" for f in res["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Calculates sales commission payouts for a period from your comp plan and a deals CSV - quota credit by deal type, split deals, tiered rates with accelerators (marginal or flat), credit and payout caps, SPIFFs, clawbacks for early cancellations, pay on booking or collection, and draws. Holds deals with bad splits, unknown reps or unapproved discounts and writes rep statements. Includes an offline stdlib Python calculator.
---
name: sales-commission-payout-calculator
description: Calculates sales commission payouts for a period from your comp plan and a deals CSV - quota credit by deal type, split deals, tiered rates with accelerators (marginal or flat), credit and payout caps, SPIFFs, clawbacks for early cancellations, pay on booking or collection, and draws. Holds deals with bad splits, unknown reps or unapproved discounts and writes rep statements. Includes an offline stdlib Python calculator.
---
# Sales Commission Payout Calculator
You help sales ops, finance and founders pay commissions that are right the first time. Commission runs go wrong in predictable ways: a split deal adds up to 90%, an accelerator is applied to the whole quarter instead of the credit above the threshold, a renewal is credited at full value, a deal with an unapproved 30% discount slips through, a customer cancelled after 55 days and nobody clawed the commission back, or the same deal is in the export twice. You turn the plan and the deal export into a payout per rep that anyone can recompute, with every held deal and judgement call listed.
You apply the plan the user gives you. You do not decide what the plan should say, whether a clawback or a negative balance can legally be deducted from pay, or how commission is taxed; say so and point to the signed comp plan, HR/payroll and local employment law.
## Files in this skill
- `scripts/commission_calc.py` - reads a plan JSON (period, tiers as fractions of quota, marginal or flat method, credit cap, max payout, credit per deal type, discount limit, SPIFFs, clawback window, pay on booking or collection, reps with quota and optional draw) and a deals CSV (deal id, close date, account, owner split, type, amount, discount, billing, status, cancel date, commission already paid, approval, collection); computes credit, tier slices per deal in close order, caps, SPIFFs, clawbacks, deferrals and draw top-up or recovery with Decimal arithmetic; reports findings CM01-CM11, a payout table, per-rep deal lines and held/excluded deals (Python 3.8+ standard library only)
- `references/plan-mechanics.md` - tiers, marginal vs flat, caps, credit rules, splits, SPIFFs, clawbacks, draws, timing, with worked numbers
- `references/checks-and-codes.md` - every finding code, its severity and what to do about it, and the input fields
- `templates/commission-statement.md` - the statement each rep receives and signs off
- `examples/comp-plan.json` - an AE plan for October 2026: 4 tiers (5/8/12/15%), credit cap 200%, max payout 8,000, renewals at 50% credit, 20% discount limit, annual-prepay SPIFF, 90-day clawback, three reps, one with a recoverable draw
- `examples/deals.csv` - 15 rows: normal deals, a 60/40 split, a renewal, an approved and an unapproved big discount, a split that adds up to 90%, a deal for an unknown rep with a thousands separator, two cancellations (inside and outside the clawback window), a September deal and a duplicate row
- `examples/payout-report.md` - the real calculator output on the example
## Workflow
### 1. Collect the plan and the deals
Ask for the comp plan document (or the relevant page) and turn it into a plan JSON like `examples/comp-plan.json`. Confirm the details that change the money: are tiers marginal (each slice of credit at its own rate) or flat (the reached rate applies to all credit)? Is attainment measured on bookings, ACV or margin? How are renewals and expansions credited? Is there a credit cap or a payout cap? When is commission earned: at booking or when the customer pays? How long is the clawback window and does it cover the full commission? Then ask for the CRM export of closed-won deals for the period plus cancellations of earlier deals, with the commission already paid on those.
### 2. Run the calculator
```
python3 scripts/commission_calc.py examples/comp-plan.json examples/deals.csv
python3 scripts/commission_calc.py plan.json deals.csv --rep ana
python3 scripts/commission_calc.py plan.json deals.csv --json
python3 scripts/commission_calc.py --self-test
```
Exit 0 = no high findings, 1 = high findings (deals held that need a decision), 2 = bad input. Without a shell, follow `references/plan-mechanics.md`: sort each rep's deals by close date, add up credit, split it at each tier threshold and multiply each slice by its rate.
### 3. Read the result
- High first: CM01 (unknown rep, bad amount or date, unknown deal type) and CM02 (split not 100%). These deals are held - get the answer from sales ops before payroll.
- Medium: CM05 (discount over the limit without approval - held), CM04 (duplicate row), CM08 (clawback applied - check the amount paid), CM11 (clawbacks bigger than earnings - negative balance).
- Low/info: deals from other periods (CM03), cancellations outside the window, caps (CM06), accelerators (CM07), deferrals (CM09), draws (CM10).
- Check one rep by hand against the per-deal rate slices before sending anything.
### 4. Resolve and re-run
For every held deal record the decision (correct split, approval found, rep assignment) in the CSV and re-run. Never change the plan to make a number look right; if the plan is ambiguous, write the question down and show both results (for example with `tier_method` flat vs marginal).
### 5. Deliver
Return the payout table for approval, a statement per rep from `templates/commission-statement.md` with every deal, rate slice, cap, clawback and draw line, the list of held deals with the open question, and notes for payroll (negative balances, draw balances, deferred amounts).
## Example result
On the example October payouts total 9,500.00 EUR, with 3 deals held. Ana Ruiz reaches 158.7% of her 60,000 quota (renewal Delta Foods credited at 50%), earns 8,280.00 on marginal tiers plus 400.00 in annual-prepay SPIFFs and is capped at the 8,000.00 maximum. Ben Okafor earns 690.00 but the Ironclad deal cancelled 55 days after closing claws back 960.00, so he is paid 0 and carries -270.00 forward. Chloe Martin earns 1,050.00, below her 1,500.00 recoverable draw, so the draw tops up 450.00 and her balance rises to 1,050.00. Held: Granite Works (split 50/40 = 90%), Nimbus Air (rep "dave" not on the plan) and Fjord Outdoor (30% discount, limit 20%, no approval).
## Rules
- Follow the signed plan; when the plan and the data disagree, hold the deal and ask - never guess a split, a rep or an approval.
- Show the arithmetic: every payout must be traceable to deal credit, tier slices and adjustments.
- Apply tiers in close-date order and say whether the plan is marginal or flat.
- Treat clawbacks and negative balances carefully: state what the plan allows and point to HR/payroll and local law before deducting from pay.
- Commission data is confidential: share each rep only their own statement.
- This is a calculation aid, not legal, tax or payroll advice.
FILE:references/plan-mechanics.md
# Plan mechanics with worked numbers
All examples: quota 10,000; tiers 5% up to 50% of quota, 8% up to 100%, 12% above.
## Quota credit
- Credit = deal amount x credit rate for the deal type (`deal_type_credit`, e.g. new 1.0, expansion 1.0, renewal 0.5) x the rep's split share.
- Use the measure the plan names (bookings, first-year ACV, TCV, margin) and export that column as `amount`.
- Attainment = total credit / quota.
## Marginal tiers (default)
Each slice of credit is paid at the rate of the band it falls in, in close-date order.
- 12,000 credit: 5,000 x 5% = 250, 5,000 x 8% = 400, 2,000 x 12% = 240 -> 890.
- Two deals of 6,000: the first earns 5,000 x 5% + 1,000 x 8% = 330, the second 4,000 x 8% + 2,000 x 12% = 560. The total is the same in any order; the per-deal split depends on close order, so keep close dates accurate.
## Flat (retroactive) tiers
The rate of the tier reached applies to all credit: 12,000 x 12% = 1,440. Flat tiers create big jumps at each threshold (9,999 credit earns 799.92, 10,000 earns 1,200 under a 8%/12% flat plan) and invite deal timing games; check the plan wording ("on all bookings" vs "on bookings above").
## Accelerators and decelerators
- An accelerator is simply a higher rate above 100% (12-15% here). A decelerator is a lower rate above a cap point, modelled as a final tier with a lower rate.
## Caps
- Credit cap (`credit_cap_attainment`, e.g. 2.0): credit above 200% of quota earns 0%.
- Payout cap (`max_payout`): commission + SPIFFs for the period cannot exceed this amount. Many plans avoid caps; if yours has one, say so in the statement.
## Splits
- `owner_split` like `ana:60;ben:40` must add up to 100%. Each rep gets their share of credit and SPIFFs. Overlay roles (SE, partner manager) that get credit on top of 100% need their own plan file.
## SPIFFs
- Fixed bonuses for deals matching all `when` fields (for example `type=new` and `billing=annual`). Split with the deal.
## Discount limits
- Deals with `discount_pct` above `max_discount_pct` need `approved=yes`; otherwise the credit is held (`unapproved_discount: hold`) or paid with a finding (`pay`).
## Clawbacks
- A deal closed in an earlier period and cancelled in this period within `clawback.window_days` of closing claws back `share` x the commission paid on it (column `commission_paid`; if missing it is estimated at the first tier rate and flagged).
- Cancelled after the window: no clawback, noted.
- Closed and cancelled in the same period: never credited.
- If clawbacks exceed earnings the payout is 0 and the negative balance is carried forward (CM11). Whether it may be deducted from future pay depends on the plan and local law.
## Booking vs collection
- `pay_on: booking`: commission is paid in the close period.
- `pay_on: collection`: credit counts toward attainment now, but commission (and SPIFF) on deals with `collected` not yes is deferred until the customer pays. Deferred amounts are listed; pay them in the period the cash arrives.
## Draws
- Recoverable draw: the rep is paid at least the draw; if earnings are lower, the top-up is added to the draw balance; later surpluses above the draw repay that balance first.
- Draw 1,500, balance 600, earned 1,050 -> paid 1,500, balance 1,050.
- Next month earned 2,900 -> 2,900 - 1,500 = 1,400 available, 1,050 recovered, paid 1,850, balance 0.
- Non-recoverable draw: a guaranteed minimum; no balance.
## Order of operations used by the calculator
1. Credit per deal (type credit x split) for won deals closed in the period, minus held and excluded deals.
2. Tier slices per deal in close order (marginal) or the reached rate on all credit (flat), with the credit cap.
3. + SPIFFs, then the payout cap.
4. - Deferred (pay on collection), - clawbacks, + carry-in.
5. Draw top-up or recovery; negative results become a carry-forward.
FILE:references/checks-and-codes.md
# Checks and finding codes
| Code | Severity | Check | What to do |
|---|---|---|---|
| CM01 | high: unknown rep, missing rep, bad amount/date, deal type with no credit rate; medium: zero or negative amount | data needed to pay the deal | fix the CRM record or add the rep/type to the plan; held deals are not paid |
| CM02 | high | owner_split shares add up to 100% | get the split confirmed by sales ops / the deal desk |
| CM03 | low | won deal closed outside the period | make sure it was paid in its own period; late-booked deals need a manual adjustment |
| CM04 | medium | the same deal_id appears twice | the second row is ignored; fix the export |
| CM05 | medium | discount above `max_discount_pct` without `approved=yes` | find the approval or keep the credit on hold |
| CM06 | info | credit cap or payout cap applied | mention it on the statement |
| CM07 | info | accelerator tier reached | check the threshold maths once by hand |
| CM08 | medium: clawback applied (estimated if commission_paid missing); low: cancelled after the window; info: closed and cancelled in this period | cancellations | confirm the amount paid originally |
| CM09 | low | commission deferred until collection | pay it in the period the cash arrives |
| CM10 | info | draw top-up or recovery, with the new balance | carry the balance into next period's plan file |
| CM11 | medium | clawbacks exceed earnings: payout 0, negative carry-forward | check the plan and local law before deducting |
## Verdict
- **High findings**: deals are held; resolve before payroll. The payable total shown excludes held deals.
- **Ready for approval**: nothing blocks the run; review medium findings with sales ops.
## Plan JSON keys
`plan_name, period{start, end, label}, currency, tier_method (marginal|flat), tiers[{up_to, rate}] (fractions; last tier without up_to), credit_cap_attainment, max_payout, deal_type_credit{type: rate}, max_discount_pct, unapproved_discount (hold|pay), spiffs[{name, when{column: value}, amount}], clawback{window_days, share}, pay_on (booking|collection), reps{id: {name, quota, draw{amount, recoverable, balance}, carry_forward}}`.
## Deals CSV columns
`deal_id, close_date, account, owner_split (ana or ana:60;ben:40), type, amount, discount_pct, billing, status (won|cancelled), cancel_date, commission_paid, approved (yes/no), collected (yes/no)`. Thousands separators (12,500) and decimal commas (1.234,50) are accepted. Extra columns can be used in SPIFF `when` conditions.
FILE:templates/commission-statement.md
# Commission statement: <rep name>
**Period:** <October 2026> **Plan:** <plan name, version/date signed> **Quota:** <60,000.00 EUR>
**Prepared by:** <name, sales ops> **Date:** <YYYY-MM-DD>
## Summary
| | Amount |
|---|---|
| Quota credit | <95,200.00> (<158.7%> of quota) |
| Commission (tiers) | <8,280.00> |
| SPIFFs | <400.00> |
| Cap | <-680.00> (max payout <8,000.00>) |
| Clawbacks | <-0.00> |
| Deferred to collection | <0.00> |
| Draw top-up / recovery | <0.00> |
| **Payout this period** | **<8,000.00>** |
| Balance carried forward | <0.00> |
## Deals
| Deal | Account | Closed | Type | Credit | Rate slices | Commission | SPIFF |
|---|---|---|---|---|---|---|---|
| <D-1001> | <Acme Logistics> | <2026-10-03> | <new> | <18,000.00> | <18,000.00 x 5.0%> | <900.00> | <200.00> |
## Adjustments
- <Clawback D-0950 cancelled 55 days after close: -960.00>
- <Held: D-1006 Fjord Outdoor, discount 30% awaiting approval - will be paid in the period it is approved>
## How it was calculated
<Marginal tiers 5% to 50% of quota, 8% to 100%, 12% to 150%, 15% above, applied in close-date order; renewals credited at 50%; credit cap 200%; max payout 8,000.>
Please review by <date>. Questions or disputes: <contact>. Disputes must name the deal and the line.
*Calculated from the plan and CRM data listed above. The signed compensation plan governs; this statement is not tax or legal advice.*
FILE:examples/comp-plan.json
{
"plan_name": "Northlake Software AE plan FY2026",
"period": {"start": "2026-10-01", "end": "2026-10-31", "label": "October 2026"},
"currency": "EUR",
"tier_method": "marginal",
"tiers": [
{"up_to": 0.5, "rate": 0.05},
{"up_to": 1.0, "rate": 0.08},
{"up_to": 1.5, "rate": 0.12},
{"rate": 0.15}
],
"credit_cap_attainment": 2.0,
"max_payout": 8000,
"deal_type_credit": {"new": 1.0, "expansion": 1.0, "renewal": 0.5},
"max_discount_pct": 20,
"unapproved_discount": "hold",
"spiffs": [
{"name": "Annual prepay on new logo", "when": {"type": "new", "billing": "annual"}, "amount": 200}
],
"clawback": {"window_days": 90, "share": 1.0},
"pay_on": "booking",
"reps": {
"ana": {"name": "Ana Ruiz", "quota": 60000},
"ben": {"name": "Ben Okafor", "quota": 50000},
"chloe": {"name": "Chloe Martin", "quota": 40000, "draw": {"amount": 1500, "recoverable": true, "balance": 600}}
}
}
FILE:examples/deals.csv
deal_id,close_date,account,owner_split,type,amount,discount_pct,billing,status,cancel_date,commission_paid,approved,collected
D-1001,2026-10-03,Acme Logistics,ana,new,18000,10,annual,won,,,,yes
D-1002,2026-10-08,Brightwave Media,ana:60;ben:40,expansion,12000,0,monthly,won,,,,yes
D-1003,2026-10-14,Corvid Health,ana,new,30000,15,annual,won,,,,no
D-1004,2026-10-21,Delta Foods,ana,renewal,24000,5,annual,won,,,,yes
D-1005,2026-10-28,Evergreen Bank,ana,new,28000,25,monthly,won,,,yes,no
D-1006,2026-10-10,Fjord Outdoor,ben,new,22000,30,annual,won,,,,yes
D-1007,2026-10-17,Granite Works,ben:50;chloe:40,new,15000,10,monthly,won,,,,yes
D-1008,2026-10-24,Harbor Clinics,ben,expansion,9000,0,monthly,won,,,,yes
D-0950,2026-08-12,Ironclad Security,ben,new,16000,10,annual,cancelled,2026-10-06,960,,
D-0871,2026-05-20,Juniper Retail,ben,new,14000,5,monthly,cancelled,2026-10-15,700,,
D-1009,2026-10-09,Kestrel Labs,chloe,new,11000,10,annual,won,,,,yes
D-1010,2026-10-30,Lumen Studio,chloe,new,6000,0,monthly,won,,,,no
D-0990,2026-09-29,Meridian Travel,chloe,new,8000,5,monthly,won,,,,yes
D-1009,2026-10-09,Kestrel Labs,chloe,new,11000,10,annual,won,,,,yes
D-1012,2026-10-19,Nimbus Air,dave,new,"12,500",10,annual,won,,,,yes
FILE:examples/payout-report.md
# Commission payouts: Northlake Software AE plan FY2026, October 2026
Period 2026-10-01 to 2026-10-31, marginal tiers (5.0% up to 50.0% of quota, 8.0% up to 100.0% of quota, 12.0% up to 150.0% of quota, 15.0% above), pay on booking, credit cap 200.0% of quota, max payout 8,000.00. Source: examples/deals.csv.
**Verdict: 2 high finding(s), 3 deal(s) held - resolve them before payroll; total payable now 9,500.00 EUR**
Findings: 2 high, 4 medium, 2 low, 3 info. Figures follow the plan file you provided; the signed comp plan and local pay law win if they differ.
## Payouts
| Rep | Quota | Credit | Attainment | Tier | Commission | SPIFFs | Cap | Clawbacks | Deferred | Draw | Payout |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Ana Ruiz | 60,000.00 | 95,200.00 | 158.7% | 4 | 8,280.00 | 400.00 | -680.00 | | | | **8,000.00** |
| Ben Okafor | 50,000.00 | 13,800.00 | 27.6% | 1 | 690.00 | 0.00 | | -960.00 | | | **0.00** |
| Chloe Martin | 40,000.00 | 17,000.00 | 42.5% | 1 | 850.00 | 200.00 | | | | +450.00 | **1,500.00** |
| **Total** | | | | | | | | | | | **9,500.00** |
## Ana Ruiz
| Deal | Account | Closed | Type | Amount | Share | Credit | Attainment after | Rate slices | Commission | SPIFF |
|---|---|---|---|---|---|---|---|---|---|---|
| D-1001 | Acme Logistics | 2026-10-03 | new | 18,000.00 | 100% | 18,000.00 | 30.0% | 18,000.00 x 5.0% | 900.00 | 200.00 |
| D-1002 | Brightwave Media | 2026-10-08 | expansion | 12,000.00 | 60% | 7,200.00 | 42.0% | 7,200.00 x 5.0% | 360.00 | |
| D-1003 | Corvid Health | 2026-10-14 | new | 30,000.00 | 100% | 30,000.00 | 92.0% | 4,800.00 x 5.0%; 25,200.00 x 8.0% | 2,256.00 | 200.00 |
| D-1004 | Delta Foods | 2026-10-21 | renewal | 24,000.00 | 100% | 12,000.00 | 112.0% | 4,800.00 x 8.0%; 7,200.00 x 12.0% | 1,248.00 | |
| D-1005 | Evergreen Bank | 2026-10-28 | new | 28,000.00 | 100% | 28,000.00 | 158.7% | 22,800.00 x 12.0%; 5,200.00 x 15.0% | 3,516.00 | |
## Ben Okafor
| Deal | Account | Closed | Type | Amount | Share | Credit | Attainment after | Rate slices | Commission | SPIFF |
|---|---|---|---|---|---|---|---|---|---|---|
| D-1002 | Brightwave Media | 2026-10-08 | expansion | 12,000.00 | 40% | 4,800.00 | 9.6% | 4,800.00 x 5.0% | 240.00 | |
| D-1008 | Harbor Clinics | 2026-10-24 | expansion | 9,000.00 | 100% | 9,000.00 | 27.6% | 9,000.00 x 5.0% | 450.00 | |
- Clawback D-0950 (Ironclad Security, cancelled 55 days after close): -960.00
- Negative balance carried forward: -270.00
## Chloe Martin
| Deal | Account | Closed | Type | Amount | Share | Credit | Attainment after | Rate slices | Commission | SPIFF |
|---|---|---|---|---|---|---|---|---|---|---|
| D-1009 | Kestrel Labs | 2026-10-09 | new | 11,000.00 | 100% | 11,000.00 | 27.5% | 11,000.00 x 5.0% | 550.00 | 200.00 |
| D-1010 | Lumen Studio | 2026-10-30 | new | 6,000.00 | 100% | 6,000.00 | 42.5% | 6,000.00 x 5.0% | 300.00 | |
- Draw: top-up 450.00, recovered 0.00, outstanding balance 1,050.00
## Held deals (need a decision)
| Deal | Account | Reason |
|---|---|---|
| D-1006 | Fjord Outdoor | discount 30% not approved |
| D-1007 | Granite Works | split 90% |
| D-1012 | Nimbus Air | unknown rep dave |
## Not counted this period
| Deal | Account | Reason |
|---|---|---|
| D-0871 | Juniper Retail | cancelled after clawback window |
| D-0990 | Meridian Travel | closed 2026-09-29 |
| D-1009 | Kestrel Labs | duplicate row |
## Findings
- **HIGH CM01** D-1012: unknown rep(s) dave in owner_split - deal held
- **HIGH CM02** D-1007: split shares add up to 90%, not 100% - deal held until sales ops confirms the split
- **MEDIUM CM04** D-1009 appears again on row 15 (first on row 12) - second row ignored
- **MEDIUM CM05** D-1006: discount 30% over the 20% limit without approval - credit held
- **MEDIUM CM08** D-0950 cancelled 55 days after close (window 90): clawback 960.00
- **MEDIUM CM11** Ben Okafor: clawbacks exceed earnings by 270.00 - payout 0, negative balance carried to next period (check your plan and local pay law before deducting)
- **LOW CM03** D-0990 closed 2026-09-29, outside October 2026 - not counted here (check it was paid in its own period)
- **LOW CM08** D-0871 cancelled 148 days after close (window 90) - no clawback
- **INFO CM06** Ana Ruiz: commission + SPIFFs 8,680.00 capped at max_payout 8,000.00 (-680.00)
- **INFO CM07** Ana Ruiz at 158.7% of quota - tier 4 (15.0%) reached for the credit above each threshold
- **INFO CM10** Chloe Martin: earned 1,050.00 is below the 1,500.00 draw - draw tops up 450.00 (recoverable: balance now 1,050.00)
Recompute check: commission per deal = sum of (credit in each tier band x tier rate); payout = commission + SPIFFs - cap - deferred - clawbacks + carry-in, then draw rules. Have sales ops and payroll approve before paying.
FILE:scripts/commission_calc.py
#!/usr/bin/env python3
"""Sales commission payout calculator (stdlib only, Python 3.8+).
Reads a compensation plan (JSON) and a deals export (CSV) and works out each rep's payout for
one period: quota credit by deal type, deal splits, tiered rates with accelerators (marginal or
flat/retroactive), credit and payout caps, SPIFFs, clawbacks for deals cancelled inside the
clawback window, payment on booking or on collection, and recoverable or non-recoverable draws.
Deals with problems (bad split, unknown rep, discount over the limit without approval,
duplicates, wrong period) are held or excluded and listed with a finding code.
Usage:
commission_calc.py PLAN.json DEALS.csv [--rep ID]... [--json]
commission_calc.py --self-test
Exit: 0 = no high findings, 1 = high findings (deals held that need a decision), 2 = bad input.
"""
import argparse, csv, datetime, io, json, sys
from collections import OrderedDict
from decimal import Decimal, ROUND_HALF_UP
SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
CENT = Decimal("0.01")
PLAN_KEYS = {"plan_name", "period", "currency", "tier_method", "tiers", "credit_cap_attainment", "max_payout",
"deal_type_credit", "max_discount_pct", "unapproved_discount", "spiffs", "clawback", "pay_on", "reps"}
class InputError(Exception):
pass
def D(x, what="number"):
if isinstance(x, Decimal):
return x
s = str(x).strip().replace(" ", "")
if s == "":
raise InputError("empty %s" % what)
if "," in s and "." in s:
s = s.replace(",", "") if s.rfind(".") > s.rfind(",") else s.replace(".", "").replace(",", ".")
elif "," in s:
s = s.replace(",", "") if len(s.split(",")[-1]) == 3 else s.replace(",", ".")
try:
return Decimal(s)
except Exception:
raise InputError("%s is not a number: %r" % (what, x))
def pdate(s, what):
try:
return datetime.date.fromisoformat(str(s).strip())
except ValueError:
raise InputError("%s must be YYYY-MM-DD, got %r" % (what, s))
def money(x):
return Decimal(x).quantize(CENT, rounding=ROUND_HALF_UP)
def fm(x):
x = money(x)
return "{:,.2f}".format(x)
def num(x):
return "{:f}".format(Decimal(x).normalize())
def pct(x):
return "%.1f%%" % (float(x) * 100)
# ---------------------------------------------------------------- plan
def load_plan(cfg):
if not isinstance(cfg, dict):
raise InputError("plan must be a JSON object")
bad = set(cfg) - PLAN_KEYS
if bad:
raise InputError("unknown plan key(s): %s" % ", ".join(sorted(bad)))
per = cfg.get("period") or {}
p = {"name": cfg.get("plan_name", "Commission plan"), "currency": cfg.get("currency", ""),
"start": pdate(per.get("start"), "period.start"), "end": pdate(per.get("end"), "period.end"),
"label": per.get("label") or "%s..%s" % (per.get("start"), per.get("end")),
"method": cfg.get("tier_method", "marginal"), "pay_on": cfg.get("pay_on", "booking"),
"cap_att": D(cfg["credit_cap_attainment"]) if cfg.get("credit_cap_attainment") is not None else None,
"max_payout": D(cfg["max_payout"]) if cfg.get("max_payout") is not None else None,
"type_credit": {k.lower(): D(v) for k, v in (cfg.get("deal_type_credit") or {"new": 1}).items()},
"max_disc": D(cfg["max_discount_pct"]) if cfg.get("max_discount_pct") is not None else None,
"unapproved": cfg.get("unapproved_discount", "hold"),
"spiffs": cfg.get("spiffs", []),
"claw_days": int((cfg.get("clawback") or {}).get("window_days", 0)),
"claw_share": D((cfg.get("clawback") or {}).get("share", 1)),
"reps": OrderedDict()}
if p["end"] < p["start"]:
raise InputError("period ends before it starts")
if p["method"] not in ("marginal", "flat"):
raise InputError("tier_method must be 'marginal' or 'flat'")
if p["pay_on"] not in ("booking", "collection"):
raise InputError("pay_on must be 'booking' or 'collection'")
if p["unapproved"] not in ("hold", "pay"):
raise InputError("unapproved_discount must be 'hold' or 'pay'")
tiers, prev = [], Decimal(0)
for i, t in enumerate(cfg.get("tiers") or []):
up = D(t["up_to"], "tier up_to") if t.get("up_to") is not None else None
if up is not None and up <= prev:
raise InputError("tier %d: up_to must increase (attainment as a fraction, e.g. 1.0 = 100%%)" % (i + 1))
if up is None and i != len(cfg["tiers"]) - 1:
raise InputError("only the last tier may omit up_to")
tiers.append((prev, up, D(t["rate"], "tier rate")))
prev = up if up is not None else prev
if not tiers:
raise InputError("plan needs at least one tier")
if any(r > 1 for _, _, r in tiers):
raise InputError("tier rates are fractions (0.08 = 8%)")
p["tiers"] = tiers
for rid, r in (cfg.get("reps") or {}).items():
dr = r.get("draw")
p["reps"][rid.lower()] = {"id": rid.lower(), "name": r.get("name", rid), "quota": D(r["quota"], "quota of %s" % rid),
"draw": D(dr["amount"]) if dr else Decimal(0), "recoverable": bool(dr and dr.get("recoverable", True)),
"balance": D(dr.get("balance", 0)) if dr else Decimal(0),
"carry": D(r.get("carry_forward", 0))}
if p["reps"][rid.lower()]["quota"] <= 0:
raise InputError("quota of %s must be positive" % rid)
if not p["reps"]:
raise InputError("plan needs at least one rep")
return p
def rate_at(p, att):
for lo, up, r in p["tiers"]:
if up is None or att < up:
return r
return p["tiers"][-1][2]
def tier_name(p, att):
for i, (lo, up, r) in enumerate(p["tiers"], 1):
if up is None or att < up:
return i
return len(p["tiers"])
# ---------------------------------------------------------------- deals
def parse_split(s, default):
s = (s or "").strip()
if not s:
return OrderedDict([(default, Decimal(100))]) if default else None
out = OrderedDict()
for part in s.replace("|", ";").split(";"):
part = part.strip()
if not part:
continue
if ":" in part:
who, share = part.split(":", 1)
out[who.strip().lower()] = out.get(who.strip().lower(), Decimal(0)) + D(share.strip().rstrip("%"), "split share")
else:
out[part.lower()] = Decimal(100)
return out
def yes(v):
return str(v or "").strip().lower() in ("yes", "y", "true", "1", "x")
def compute(p, deals_text, only=None):
F = []
rows = list(csv.DictReader(io.StringIO(deals_text)))
if not rows:
raise InputError("deals CSV has no rows")
need = {"deal_id", "close_date", "amount"}
if not need <= set(rows[0]):
raise InputError("deals CSV needs columns %s (has %s)" % (", ".join(sorted(need)), ", ".join(rows[0])))
credited = {rid: [] for rid in p["reps"]}
held, excluded, claws, seen = [], [], [], {}
for n, r in enumerate(rows, 2):
did = (r.get("deal_id") or "").strip() or "row %d" % n
acct = r.get("account", "")
try:
close = pdate(r.get("close_date"), "close_date")
amount = D(r.get("amount"), "amount")
disc = D(r.get("discount_pct") or 0, "discount_pct")
except InputError as e:
F.append(("high", "CM01", "%s (row %d): %s - deal not counted" % (did, n, e), did)); held.append((did, acct, "bad data")); continue
key = did.lower()
if key in seen:
F.append(("medium", "CM04", "%s appears again on row %d (first on row %d) - second row ignored" % (did, n, seen[key]), did)); excluded.append((did, acct, "duplicate row")); continue
seen[key] = n
status = (r.get("status") or "won").strip().lower()
split = parse_split(r.get("owner_split") or r.get("rep"), None)
if not split:
F.append(("high", "CM01", "%s: no rep / owner_split - deal not counted" % did, did)); held.append((did, acct, "no rep")); continue
unknown = [w for w in split if w not in p["reps"]]
if status == "cancelled":
cdate = r.get("cancel_date")
try:
cdate = pdate(cdate, "cancel_date") if cdate else None
except InputError as e:
F.append(("high", "CM01", "%s: %s" % (did, e), did)); continue
if not cdate or not (p["start"] <= cdate <= p["end"]):
excluded.append((did, acct, "cancelled outside this period")); continue
if p["start"] <= close <= p["end"]:
F.append(("info", "CM08", "%s closed and was cancelled in this period - not credited" % did, did)); excluded.append((did, acct, "cancelled in period")); continue
age = (cdate - close).days
if age > p["claw_days"]:
F.append(("low", "CM08", "%s cancelled %d days after close (window %d) - no clawback" % (did, age, p["claw_days"]), did)); excluded.append((did, acct, "cancelled after clawback window")); continue
paid = r.get("commission_paid")
if paid and str(paid).strip():
base = D(paid, "commission_paid"); est = False
else:
tc = p["type_credit"].get((r.get("type") or "new").strip().lower(), Decimal(1))
base = amount * tc * p["tiers"][0][2]; est = True
for who, share in split.items():
if who in p["reps"]:
amt = money(base * p["claw_share"] * share / 100)
claws.append({"rep": who, "deal": did, "account": acct, "amount": amt, "age": age, "estimated": est})
F.append(("medium", "CM08", "%s cancelled %d days after close (window %d): clawback %s%s" % (did, age, p["claw_days"], fm(base * p["claw_share"]), " (estimated at the first tier rate - commission_paid missing)" if est else ""), did))
continue
if status not in ("won", "closed_won", "closed won"):
excluded.append((did, acct, "status %s" % status)); continue
if not (p["start"] <= close <= p["end"]):
F.append(("low", "CM03", "%s closed %s, outside %s - not counted here (check it was paid in its own period)" % (did, close, p["label"]), did)); excluded.append((did, acct, "closed %s" % close)); continue
if unknown:
F.append(("high", "CM01", "%s: unknown rep(s) %s in owner_split - deal held" % (did, ", ".join(unknown)), did)); held.append((did, acct, "unknown rep %s" % ", ".join(unknown))); continue
total = sum(split.values())
if total != 100:
F.append(("high", "CM02", "%s: split shares add up to %s%%, not 100%% - deal held until sales ops confirms the split" % (did, num(total)), did)); held.append((did, acct, "split %s%%" % num(total))); continue
dtype = (r.get("type") or "new").strip().lower()
if dtype not in p["type_credit"]:
F.append(("high", "CM01", "%s: deal type '%s' has no credit rate in the plan - deal held" % (did, dtype), did)); held.append((did, acct, "unknown type %s" % dtype)); continue
if amount <= 0:
F.append(("medium", "CM01", "%s: amount %s is not positive - not counted (use a cancellation for reversals)" % (did, amount), did)); excluded.append((did, acct, "amount <= 0")); continue
if p["max_disc"] is not None and disc > p["max_disc"] and not yes(r.get("approved")):
if p["unapproved"] == "hold":
F.append(("medium", "CM05", "%s: discount %s%% over the %s%% limit without approval - credit held" % (did, num(disc), num(p["max_disc"])), did)); held.append((did, acct, "discount %s%% not approved" % num(disc))); continue
F.append(("medium", "CM05", "%s: discount %s%% over the %s%% limit without approval - paid anyway (unapproved_discount=pay)" % (did, num(disc), num(p["max_disc"])), did))
credit_total = amount * p["type_credit"][dtype]
deferred = p["pay_on"] == "collection" and not yes(r.get("collected"))
for who, share in split.items():
spiff = Decimal(0)
for s in p["spiffs"]:
if all(str(r.get(k, "")).strip().lower() == str(v).lower() for k, v in (s.get("when") or {}).items()):
spiff += D(s["amount"]) * share / 100
credited[who].append({"deal": did, "account": acct, "close": close, "type": dtype, "amount": amount, "share": share,
"credit": credit_total * share / 100, "spiff": spiff, "deferred": deferred})
# per rep
reps = []
for rid, rep in p["reps"].items():
if only and rid not in only:
continue
ds = sorted(credited[rid], key=lambda d: (d["close"], d["deal"]))
q = rep["quota"]
cap_credit = q * p["cap_att"] if p["cap_att"] is not None else None
cum = Decimal(0)
total_credit = sum((d["credit"] for d in ds), Decimal(0))
att = total_credit / q
flat_rate = rate_at(p, min(att, p["cap_att"]) if p["cap_att"] is not None else att)
for d in ds:
a, b = cum, cum + d["credit"]
cum = b
comm, slices = Decimal(0), []
if p["method"] == "flat":
eligible = b if cap_credit is None else min(b, cap_credit)
part = max(Decimal(0), eligible - a)
comm = part * flat_rate
slices.append("%s x %s" % (fm(part), pct(flat_rate)))
if part < d["credit"]:
slices.append("%s over the credit cap x 0%%" % fm(d["credit"] - part))
else:
for lo, up, rate in p["tiers"]:
lo_c, up_c = lo * q, (up * q if up is not None else None)
if cap_credit is not None:
up_c = cap_credit if up_c is None else min(up_c, cap_credit)
if lo_c >= cap_credit:
continue
x0, x1 = max(a, lo_c), (b if up_c is None else min(b, up_c))
if x1 > x0:
comm += (x1 - x0) * rate
slices.append("%s x %s" % (fm(x1 - x0), pct(rate)))
if cap_credit is not None and b > cap_credit:
slices.append("%s over the credit cap x 0%%" % fm(b - max(a, cap_credit)))
d["commission"] = money(comm)
d["slices"] = slices
d["att_after"] = b / q
commission = sum((d["commission"] for d in ds), Decimal(0))
spiffs = money(sum((d["spiff"] for d in ds), Decimal(0)))
earned = commission + spiffs
capped = Decimal(0)
if p["max_payout"] is not None and earned > p["max_payout"]:
capped = earned - p["max_payout"]
F.append(("info", "CM06", "%s: commission + SPIFFs %s capped at max_payout %s (-%s)" % (rep["name"], fm(earned), fm(p["max_payout"]), fm(capped)), rid))
earned = p["max_payout"]
if cap_credit is not None and total_credit > cap_credit:
F.append(("info", "CM06", "%s: credit above %s of quota (%s) earns no commission" % (rep["name"], pct(p["cap_att"]), fm(total_credit - cap_credit)), rid))
if att >= 1:
F.append(("info", "CM07", "%s at %s of quota - tier %d (%s) reached%s" % (rep["name"], pct(att), tier_name(p, att), pct(rate_at(p, att)), " and applied to all credit (flat method)" if p["method"] == "flat" else " for the credit above each threshold"), rid))
deferred = money(sum((d["commission"] + d["spiff"] for d in ds if d["deferred"]), Decimal(0)))
if deferred and capped:
deferred = money(deferred * earned / (earned + capped))
if deferred:
F.append(("low", "CM09", "%s: %s deferred until the customer pays (pay_on collection)" % (rep["name"], fm(deferred)), rid))
claw = sum((c["amount"] for c in claws if c["rep"] == rid), Decimal(0))
payable = earned - deferred - claw + rep["carry"]
draw_paid, recovered, bal = Decimal(0), Decimal(0), rep["balance"]
if rep["draw"]:
if payable < rep["draw"]:
draw_paid = rep["draw"] - max(payable, Decimal(0))
if rep["recoverable"]:
bal += draw_paid
F.append(("info", "CM10", "%s: earned %s is below the %s draw - draw tops up %s%s" % (rep["name"], fm(max(payable, Decimal(0))), fm(rep["draw"]), fm(draw_paid), (" (recoverable: balance now %s)" % fm(bal)) if rep["recoverable"] else " (non-recoverable)"), rid))
payout = rep["draw"]
if payable < 0:
bal += -payable if rep["recoverable"] else 0
else:
recovered = min(bal, payable - rep["draw"]) if rep["recoverable"] else Decimal(0)
bal -= recovered
payout = payable - recovered
if recovered:
F.append(("info", "CM10", "%s: %s of the outstanding draw recovered (balance now %s)" % (rep["name"], fm(recovered), fm(bal)), rid))
else:
payout = payable
carry_out = Decimal(0)
if payout < 0:
carry_out = payout
F.append(("medium", "CM11", "%s: clawbacks exceed earnings by %s - payout 0, negative balance carried to next period (check your plan and local pay law before deducting)" % (rep["name"], fm(-payout)), rid))
payout = Decimal(0)
reps.append({"id": rid, "name": rep["name"], "quota": q, "credit": total_credit, "attainment": att,
"tier": tier_name(p, att), "commission": commission, "spiffs": spiffs, "capped": capped,
"clawbacks": claw, "deferred": deferred, "carry_in": rep["carry"], "draw_topup": draw_paid,
"draw_recovered": recovered, "draw_balance": bal, "carry_out": carry_out, "payout": money(payout), "deals": ds})
F.sort(key=lambda f: (SEV[f[0]], f[1], str(f[3])))
return {"plan": p, "reps": reps, "held": held, "excluded": excluded, "clawbacks": claws, "findings": F}
# ---------------------------------------------------------------- report
def report(res, title):
p, F = res["plan"], res["findings"]
c = {s: sum(1 for f in F if f[0] == s) for s in SEV}
cur = (" " + p["currency"]) if p["currency"] else ""
total = sum((r["payout"] for r in res["reps"]), Decimal(0))
L = ["# Commission payouts: %s, %s" % (p["name"], p["label"]), ""]
L.append("Period %s to %s, %s tiers (%s), pay on %s%s%s. Source: %s." % (
p["start"], p["end"], p["method"], ", ".join("%s%s" % (pct(r), (" up to %s of quota" % pct(u)) if u is not None else " above") for _, u, r in p["tiers"]),
p["pay_on"], (", credit cap %s of quota" % pct(p["cap_att"])) if p["cap_att"] is not None else "",
(", max payout %s" % fm(p["max_payout"])) if p["max_payout"] is not None else "", title))
L.append("")
if c["high"]:
L.append("**Verdict: %d high finding(s), %d deal(s) held - resolve them before payroll; total payable now %s%s**" % (c["high"], len(res["held"]), fm(total), cur))
else:
L.append("**Verdict: ready for approval - total payable %s%s%s**" % (fm(total), cur, " (medium findings to review)" if c["medium"] else ""))
L.append("")
L.append("Findings: %d high, %d medium, %d low, %d info. Figures follow the plan file you provided; the signed comp plan and local pay law win if they differ." % (c["high"], c["medium"], c["low"], c["info"]))
L += ["", "## Payouts", "", "| Rep | Quota | Credit | Attainment | Tier | Commission | SPIFFs | Cap | Clawbacks | Deferred | Draw | Payout |", "|---|---|---|---|---|---|---|---|---|---|---|---|"]
for r in res["reps"]:
draw = ("+%s" % fm(r["draw_topup"])) if r["draw_topup"] else (("-%s recovered" % fm(r["draw_recovered"])) if r["draw_recovered"] else "")
L.append("| %s | %s | %s | %s | %d | %s | %s | %s | %s | %s | %s | **%s** |" % (
r["name"], fm(r["quota"]), fm(r["credit"]), pct(r["attainment"]), r["tier"], fm(r["commission"]), fm(r["spiffs"]),
("-" + fm(r["capped"])) if r["capped"] else "", ("-" + fm(r["clawbacks"])) if r["clawbacks"] else "",
fm(r["deferred"]) if r["deferred"] else "", draw, fm(r["payout"])))
L.append("| **Total** | | | | | | | | | | | **%s** |" % fm(total))
for r in res["reps"]:
L += ["", "## %s" % r["name"], ""]
if not r["deals"]:
L.append("No credited deals this period.")
else:
L += ["| Deal | Account | Closed | Type | Amount | Share | Credit | Attainment after | Rate slices | Commission | SPIFF |", "|---|---|---|---|---|---|---|---|---|---|---|"]
for d in r["deals"]:
L.append("| %s | %s | %s | %s | %s | %s%% | %s | %s | %s | %s | %s |" % (
d["deal"], d["account"], d["close"], d["type"], fm(d["amount"]), num(d["share"]), fm(d["credit"]),
pct(d["att_after"]), "; ".join(d["slices"]) or "-", fm(d["commission"]) + (" (deferred)" if d["deferred"] else ""), fm(d["spiff"]) if d["spiff"] else ""))
extra = []
for cl in [x for x in res["clawbacks"] if x["rep"] == r["id"]]:
extra.append("- Clawback %s (%s, cancelled %d days after close): -%s%s" % (cl["deal"], cl["account"], cl["age"], fm(cl["amount"]), " (estimated)" if cl["estimated"] else ""))
if r["carry_in"]:
extra.append("- Carried in from last period: %s" % fm(r["carry_in"]))
if r["draw_topup"] or r["draw_recovered"] or r["draw_balance"]:
extra.append("- Draw: top-up %s, recovered %s, outstanding balance %s" % (fm(r["draw_topup"]), fm(r["draw_recovered"]), fm(r["draw_balance"])))
if r["carry_out"]:
extra.append("- Negative balance carried forward: %s" % fm(r["carry_out"]))
L += [""] + extra if extra else []
if res["held"]:
L += ["", "## Held deals (need a decision)", "", "| Deal | Account | Reason |", "|---|---|---|"]
L += ["| %s | %s | %s |" % h for h in res["held"]]
if res["excluded"]:
L += ["", "## Not counted this period", "", "| Deal | Account | Reason |", "|---|---|---|"]
L += ["| %s | %s | %s |" % h for h in res["excluded"]]
L += ["", "## Findings", ""]
L += ["- **%s %s** %s" % (s.upper(), code, m) for s, code, m, _ in F] or ["- none"]
L += ["", "Recompute check: commission per deal = sum of (credit in each tier band x tier rate); payout = commission + SPIFFs - cap - deferred - clawbacks + carry-in, then draw rules. Have sales ops and payroll approve before paying."]
return "\n".join(L) + "\n"
def to_json(res):
def j(x):
if isinstance(x, Decimal):
return float(money(x))
if isinstance(x, datetime.date):
return x.isoformat()
if isinstance(x, dict):
return {k: j(v) for k, v in x.items()}
if isinstance(x, (list, tuple)):
return [j(v) for v in x]
return x
out = {"period": res["plan"]["label"], "reps": [], "held": [list(h) for h in res["held"]], "excluded": [list(h) for h in res["excluded"]],
"clawbacks": j(res["clawbacks"]), "findings": [{"severity": s, "code": c, "message": m} for s, c, m, _ in res["findings"]]}
for r in res["reps"]:
rr = {k: v for k, v in r.items() if k != "deals"}
rr["attainment"] = round(float(r["attainment"]), 4)
rr = j(rr)
rr["deals"] = [j({k: v for k, v in d.items() if k not in ("att_after",)}) for d in r["deals"]]
out["reps"].append(rr)
return out
# ---------------------------------------------------------------- self-test
def self_test():
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
base = {"period": {"start": "2026-10-01", "end": "2026-10-31"}, "tiers": [{"up_to": 0.5, "rate": 0.05}, {"up_to": 1.0, "rate": 0.08}, {"rate": 0.12}],
"reps": {"a": {"quota": 10000}, "b": {"quota": 10000}}}
H = "deal_id,close_date,owner_split,type,amount,discount_pct,billing,status,cancel_date,commission_paid,approved,collected\n"
def run(rows, **over):
cfg = dict(base); cfg.update(over)
return compute(load_plan(cfg), H + rows)
def rep(res, rid):
return next(r for r in res["reps"] if r["id"] == rid)
def codes(res):
return [(f[0], f[1]) for f in res["findings"]]
r = run("1,2026-10-05,a,new,4000,,,won,,,,\n")
t("below first threshold: 4000 x 5% = 200", rep(r, "a")["commission"] == Decimal("200.00"))
r = run("1,2026-10-05,a,new,12000,,,won,,,,\n")
t("marginal: 5000x5% + 5000x8% + 2000x12% = 890", rep(r, "a")["commission"] == Decimal("890.00"))
r = run("1,2026-10-05,a,new,12000,,,won,,,,\n", tier_method="flat")
t("flat: 12000 x 12% = 1440", rep(r, "a")["commission"] == Decimal("1440.00"))
r = run("1,2026-10-05,a,new,6000,,,won,,,,\n2,2026-10-20,a,new,6000,,,won,,,,\n")
t("tiers follow close order across deals", [d["commission"] for d in rep(r, "a")["deals"]] == [Decimal("330.00"), Decimal("560.00")])
t("accelerator reached -> CM07", ("info", "CM07") in codes(r))
r = run("1,2026-10-05,a,new,30000,,,won,,,,\n", credit_cap_attainment=2.0)
t("credit cap at 200%: 250+400+1200 = 1850", rep(r, "a")["commission"] == Decimal("1850.00") and ("info", "CM06") in codes(r))
r = run("1,2026-10-05,a,new,30000,,,won,,,,\n", max_payout=1000)
t("max_payout caps the payout", rep(r, "a")["payout"] == Decimal("1000.00"))
r = run("1,2026-10-05,a,renewal,10000,,,won,,,,\n", deal_type_credit={"new": 1, "renewal": 0.5})
t("renewal credited at 50%", rep(r, "a")["credit"] == Decimal("5000") and rep(r, "a")["commission"] == Decimal("250.00"))
r = run("1,2026-10-05,a:60;b:40,new,10000,,,won,,,,\n")
t("split 60/40 credits both reps", rep(r, "a")["credit"] == Decimal("6000") and rep(r, "b")["credit"] == Decimal("4000"))
r = run("1,2026-10-05,a:60;b:30,new,10000,,,won,,,,\n")
t("split not 100% -> CM02 high, held", ("high", "CM02") in codes(r) and rep(r, "a")["credit"] == 0 and r["held"])
r = run("1,2026-10-05,zed,new,10000,,,won,,,,\n")
t("unknown rep -> CM01 high", ("high", "CM01") in codes(r))
r = run("1,2026-10-05,a,new,10000,30,,won,,,,\n", max_discount_pct=20)
t("discount over limit unapproved -> CM05 held", ("medium", "CM05") in codes(r) and rep(r, "a")["credit"] == 0)
r = run("1,2026-10-05,a,new,10000,30,,won,,,yes,\n", max_discount_pct=20)
t("approved discount is paid", rep(r, "a")["credit"] == Decimal("10000"))
r = run("1,2026-09-28,a,new,10000,,,won,,,,\n")
t("closed outside period -> CM03, not counted", ("low", "CM03") in codes(r) and rep(r, "a")["credit"] == 0)
r = run("1,2026-10-05,a,new,4000,,,won,,,,\n1,2026-10-05,a,new,4000,,,won,,,,\n")
t("duplicate deal id -> CM04, counted once", ("medium", "CM04") in codes(r) and rep(r, "a")["credit"] == Decimal("4000"))
r = run("1,2026-10-05,a,new,4000,,,won,,,,\n9,2026-08-20,a,new,8000,,,cancelled,2026-10-10,400,,\n", clawback={"window_days": 90})
t("cancelled within window -> clawback of paid commission", rep(r, "a")["clawbacks"] == Decimal("400.00") and rep(r, "a")["payout"] == 0 and rep(r, "a")["carry_out"] == Decimal("-200.00") and ("medium", "CM11") in codes(r))
r = run("9,2026-05-01,a,new,8000,,,cancelled,2026-10-10,400,,\n", clawback={"window_days": 90})
t("cancelled after window -> no clawback", rep(r, "a")["clawbacks"] == 0 and ("low", "CM08") in codes(r))
r = run("9,2026-09-20,a,new,8000,,,cancelled,2026-10-10,,,\n", clawback={"window_days": 90})
t("missing commission_paid -> estimated at first tier", rep(r, "a")["clawbacks"] == Decimal("400.00"))
r = run("1,2026-10-05,a,new,4000,,annual,won,,,,\n", spiffs=[{"name": "Annual", "when": {"billing": "annual"}, "amount": 150}])
t("SPIFF added", rep(r, "a")["spiffs"] == Decimal("150.00") and rep(r, "a")["payout"] == Decimal("350.00"))
r = run("1,2026-10-05,a,new,4000,,,won,,,,no\n", pay_on="collection")
t("pay on collection defers unpaid deals", rep(r, "a")["deferred"] == Decimal("200.00") and rep(r, "a")["payout"] == 0)
cfg = dict(base, reps={"a": {"quota": 10000, "draw": {"amount": 500, "recoverable": True, "balance": 0}}})
res = compute(load_plan(cfg), H + "1,2026-10-05,a,new,4000,,,won,,,,\n")
t("recoverable draw tops up and builds balance", rep(res, "a")["payout"] == Decimal("500.00") and rep(res, "a")["draw_balance"] == Decimal("300"))
cfg = dict(base, reps={"a": {"quota": 10000, "draw": {"amount": 500, "recoverable": True, "balance": 300}}})
res = compute(load_plan(cfg), H + "1,2026-10-05,a,new,12000,,,won,,,,\n")
t("surplus recovers draw balance", rep(res, "a")["draw_recovered"] == Decimal("300") and rep(res, "a")["payout"] == Decimal("590.00"))
cfg = dict(base, reps={"a": {"quota": 10000, "draw": {"amount": 500, "recoverable": False}}})
res = compute(load_plan(cfg), H + "1,2026-10-05,a,new,4000,,,won,,,,\n")
t("non-recoverable draw: no balance", rep(res, "a")["payout"] == Decimal("500.00") and rep(res, "a")["draw_balance"] == 0)
t("thousands separator parsed", D("12,000") == Decimal("12000") and D("1.234,50") == Decimal("1234.50"))
for label, bad in [("tiers out of order", dict(base, tiers=[{"up_to": 1, "rate": 0.05}, {"up_to": 0.5, "rate": 0.08}])),
("rate as percent", dict(base, tiers=[{"rate": 8}])), ("unknown key", dict(base, quotas=1))]:
try:
load_plan(bad); ok = False
except InputError:
ok = True
t("bad plan rejected: " + label, ok)
rr = run("1,2026-10-05,a,new,4000,,,won,,,,\n")
t("report has verdict and payouts table", "**Verdict:" in report(rr, "t") and "## Payouts" in report(rr, "t"))
for name, ok in tests:
print(("PASS " if ok else "FAIL ") + name)
passed = sum(ok for _, ok in tests)
print("%d/%d passed" % (passed, len(tests)))
return 0 if passed == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Calculate sales commission payouts for one period.")
ap.add_argument("plan", nargs="?")
ap.add_argument("deals", nargs="?")
ap.add_argument("--rep", action="append", default=[], help="only show this rep id (repeatable)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.plan or not a.deals:
ap.print_usage(sys.stderr)
return 2
try:
try:
cfg = json.load(open(a.plan, encoding="utf-8"))
except json.JSONDecodeError as e:
raise InputError("plan is not valid JSON: %s" % e)
p = load_plan(cfg)
res = compute(p, open(a.deals, encoding="utf-8-sig").read(), [x.lower() for x in a.rep] or None)
except (OSError, InputError, KeyError) as e:
print("error: %s" % (("missing field %s" % e) if isinstance(e, KeyError) else e), file=sys.stderr)
return 2
if a.json:
print(json.dumps(to_json(res), indent=2))
else:
sys.stdout.write(report(res, a.deals))
return 1 if any(f[0] == "high" for f in res["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Checks a GitHub CODEOWNERS file against the real repository file list - works out who owns every file (last matching line wins), then reports invalid or unknown owners, unowned files and critical paths left to the catch-all rule, rules that match nothing or are fully overridden by later lines, unsupported ! and [ ] syntax, duplicates, empty teams and one-person bottlenecks. Includes an offline stdlib Python checker.
---
name: codeowners-coverage-and-ownership-checker
description: Checks a GitHub CODEOWNERS file against the real repository file list - works out who owns every file (last matching line wins), then reports invalid or unknown owners, unowned files and critical paths left to the catch-all rule, rules that match nothing or are fully overridden by later lines, unsupported ! and [ ] syntax, duplicates, empty teams and one-person bottlenecks. Includes an offline stdlib Python checker.
---
# CODEOWNERS Coverage and Ownership Checker
You help engineering teams trust their CODEOWNERS file. Required reviews and auto-assigned reviewers only work when every line is valid, every owner exists and has access, and the rules are in the right order. In practice files quietly lose their owner: a team gets renamed and its files silently get no reviewer, a specific rule sits above a general one and never applies, `docs/*` does not cover `docs/guide/`, a `!` negation copied from .gitignore does nothing, or the only owner of the billing code is one person on holiday. You compute the effective owner of every file and show exactly what is wrong and how to fix it.
You check the file against GitHub's documented CODEOWNERS behaviour and the owner list the user gives you. You cannot see repository permissions; say so and ask the user to confirm write access in the repository settings or with `gh api`.
## Files in this skill
- `scripts/codeowners_check.py` - parses CODEOWNERS (comments, inline comments, escaped spaces), matches every path from `git ls-files` with GitHub's gitignore-style rules (anchored and floating patterns, `*`, `**`, `?`, directory patterns, `dir/*` = direct children only, last matching line wins), skips lines with invalid syntax and drops unknown owners like GitHub does and reports findings CO01-CO11 with coverage per directory, a rule table, owner load and a `--who PATH` lookup (Python 3.8+ standard library only)
- `references/codeowners-syntax-and-matching.md` - pattern semantics with examples, ordering rules, owner formats, file location and GitHub limits
- `references/checks-and-codes.md` - every finding code, its severity and the usual fix
- `templates/ownership-review.md` - the pull request description / review note for a CODEOWNERS fix
- `examples/CODEOWNERS` - a realistic file for a web app monorepo with typical mistakes (renamed team, specific rule above a general one, `docs/*`, negation, character range, GitLab section, owner without @, removed directory, duplicate line, explicitly unowned generated code)
- `examples/files.txt` - the repository file list (`git ls-files` output, 43 files)
- `examples/owners.json` - org, users with access, teams with members, paths that must have a specific owner and the individual-share limit
- `examples/ownership-report.md` - the real checker output on the example
## Workflow
### 1. Collect the inputs
Ask for the CODEOWNERS file (from `.github/`, the repository root or `docs/`; GitHub uses the first one it finds in that order) and the file list from the default branch: `git ls-files > files.txt`. Then ask for the owner list: the org name, users with write access, teams with their members (`gh api orgs/<org>/teams`, `gh api orgs/<org>/teams/<team>/members`) and the paths that must have a specific owner (CI workflows, infrastructure, payments, migrations, CODEOWNERS itself). Write them into a JSON like `examples/owners.json`. Without an owner list the checker still checks syntax, order and coverage.
### 2. Run the checker
```
python3 scripts/codeowners_check.py examples/CODEOWNERS examples/files.txt --owners examples/owners.json
python3 scripts/codeowners_check.py .github/CODEOWNERS files.txt --owners owners.json --who services/api/billing/invoices.py
python3 scripts/codeowners_check.py .github/CODEOWNERS files.txt --json
python3 scripts/codeowners_check.py --self-test
```
Exit 0 = no high findings, 1 = high findings, 2 = bad input. Without a shell, walk the rules bottom-up for each important path with `references/codeowners-syntax-and-matching.md`: the first line from the bottom that matches is the owner.
### 3. Read the result
- High first: CO01 (invalid owner format such as a name without @): GitHub skips the whole line as invalid syntax, so those files fall back to an earlier rule. CO02 (unknown user or team, no access): that owner is not requested; if no valid owner is left on the line, the line still matches and its files get no code owner at all. CO03 high: files under a must-own path have no owner.
- Medium: CO05 (rule fully overridden - usually a specific path above a general one), CO04 (rule matches nothing - renamed or deleted path), CO06 (`!` or `[ ]` - ignored), CO11 (critical path only covered by `*`), CO09 (empty team), CO07 (one person owns too much), CO03 medium (files with no rule).
- Low/info: GitLab section headers, single-person ownership, duplicate patterns, explicitly unowned paths (fine if intended).
- Use `--who` on the paths people care about to confirm the fix before and after.
### 4. Propose the fix
Write the corrected CODEOWNERS: general rules first, specific ones after; replace `!` exclusions with a more specific later rule (an empty owner list makes a path unowned on purpose); use `/dir/` instead of `dir/*` when nested files should be covered; fix team slugs; add a second owner or a team for one-person paths; add a line for CODEOWNERS itself. Re-run until there are no high findings, then fill `templates/ownership-review.md`.
### 5. Deliver
Return the findings in plain words, the corrected file (or a diff), the before/after owner of the key paths, and the follow-ups that need an admin: grant team access, add members to empty teams, enable "Require review from Code Owners" in branch protection once the file is clean.
## Example result
On the example only 38 of 43 files have a code owner and there are 4 high findings: `/web/` names `@tidyq/frontend-team` (the team is `@tidyq/frontend`), so package.json, App.tsx, Home.tsx and Checkout.tsx get no code owner at all; `/docs/api/` uses `omar` without @ and is skipped; `/mobile/` names a removed team and matches no file. `/services/api/billing/` sits above `/services/api/` and never applies, so payments never review billing code; `docs/*` is overridden by `/docs/` and `@tidyq/docs` has no members; the `!` and `[a-z]` lines are ignored; Terraform modules and dependabot.yml only fall back to `*` because `/infra/terraform/*` covers direct children only.
## Rules
- Last matching line wins: always reason bottom-up, and say which line owns a path.
- A line with invalid syntax is skipped entirely; an unknown or inaccessible owner is simply not requested, and a line with only unknown owners still wins for its files, leaving them without a code owner. Never assume an earlier rule takes over in that case.
- Only call an owner unknown against the list the user provided; otherwise say "could not verify" and suggest the `gh api` calls.
- Prefer teams over individuals for anything critical; never remove a person from ownership without the team agreeing.
- Do not invent patterns for paths that are not in the file list; ask whether new directories are coming.
- Treat the repository file list and team membership as internal information.
FILE:references/codeowners-syntax-and-matching.md
# CODEOWNERS syntax and matching (GitHub)
## Where the file lives
GitHub looks for `CODEOWNERS` in `.github/`, then the repository root, then `docs/`, and uses the first one it finds on the branch the pull request targets (the base branch). Files over 3 MB are ignored. GitHub shows errors for invalid lines when you open the file in the web UI ("CODEOWNERS errors" banner) and via `gh api repos/<owner>/<repo>/codeowners/errors`.
## Line format
```
<pattern> <owner> [<owner> ...] # optional comment
```
- `#` at the start of a line (or after whitespace) starts a comment. Unlike .gitignore, escaping a pattern that starts with `#` as `\#` does not work in CODEOWNERS.
- Paths are case sensitive, even if your laptop's file system is not.
- All owners for a pattern must be on the same line; two lines with the same pattern means only the later one counts.
- Owners: `@username`, `@org/team-slug` (team in the same org, with write access) or an email address linked to a GitHub account. A bare name such as `omar` is invalid.
- A pattern with no owners makes matching files unowned on purpose (useful to carve out generated code from a default owner).
- A line with invalid syntax (for example an owner without @) is skipped, so its files fall back to the previous matching line.
- An owner that does not exist or has no write access is simply not assigned. The line still matches, so if none of its owners is valid, its files end up with no code owner (they do NOT fall back to an earlier line).
## Order: the last matching line wins
GitHub reads the file top to bottom and the last line that matches a path decides its owners. Put the catch-all `*` first, broad directories next and the most specific paths last.
```
* @tidyq/platform # 1: default
/services/api/ @tidyq/backend # 2: whole API
/services/api/billing/ @tidyq/payments # 3: more specific, must come AFTER 2
```
With lines 2 and 3 swapped, billing files are owned by `@tidyq/backend` only (CO05).
## Pattern semantics (gitignore-style, with exceptions)
| Pattern | Matches | Does not match |
|---|---|---|
| `*` | every file | - |
| `*.js` | `app.js`, `web/lib/util.js` (any depth) | `app.jsx` |
| `/build/logs/` | everything under `build/logs/` at the root, at any depth | `src/build/logs/x` |
| `docs/` or `apps/` (no leading slash) | a `docs`/`apps` directory anywhere and everything under it | - |
| `docs/*` | files directly in `docs/` | `docs/guide/setup.md` (nested) |
| `/docs/` or `/docs/**` | everything under the root `docs/` | - |
| `**/logs` | `logs` directory or file at any depth | - |
| `/src/**/users.py` | `src/users.py`, `src/api/v2/users.py` | - |
| `README.md` | `README.md` at any depth | - |
| `/README.md` | only the root `README.md` | `sub/README.md` |
| `src/ap?.py` | `src/app.py` | `src/apps.py` |
Rules of thumb:
- A pattern with a slash at the start or in the middle is anchored to the repository root; one without (apart from a trailing slash) floats and matches at any depth.
- A trailing slash means "this directory and everything in it".
- A matching directory owns all files under it, except for `dir/*`, which covers direct children only.
## Not supported (the line is ignored or flagged)
- `!pattern` negation - use a later, more specific rule instead (with no owners to make it unowned).
- `[a-z]` character ranges - list the paths or use `*`.
- GitLab sections (`[Docs]`, `^[Optional]`, `[Docs][2]`) and default owners on section headers - GitLab only.
## Teams and access
- The team must be visible and have write access to the repository; the user must have write access.
- An empty team gets the review request but nobody is notified.
- Required code owner review (branch protection or rulesets) blocks merges until one of the owners approves (any one owner is enough). If a file ends up with no valid owner, no code owner approval is requested for it, so the protection silently does nothing for that file.
## Getting the inputs
```
git ls-files > files.txt
gh api orgs/<org>/teams --paginate -q '.[].slug'
gh api orgs/<org>/teams/<slug>/members --paginate -q '.[].login'
gh api repos/<owner>/<repo>/collaborators --paginate -q '.[] | select(.permissions.push) | .login'
gh api repos/<owner>/<repo>/codeowners/errors
```
FILE:references/checks-and-codes.md
# Checks and finding codes
| Code | Severity | Check | Typical fix |
|---|---|---|---|
| CO01 | high | owner is not `@user`, `@org/team` or an email (e.g. `omar`); pattern cannot be parsed | add the @ / fix the slug; the whole line is skipped until then |
| CO02 | high | user not in your users list, team not in your teams list, or team from another org (only with `--owners`); a second CO02 line says when no valid owner is left on a line, so its files get no code owner | fix the slug (the checker suggests a close team), give the team write access, or remove the owner |
| CO03 | high: unowned file under a `require_owner` path; medium: other files with no rule or only unknown owners (explicitly unowned files are CO10) | coverage | add a rule, or a catch-all `*` default |
| CO04 | medium | the pattern matches no file in the list | path renamed or deleted: update or remove the line |
| CO05 | medium | every file the rule matches is owned by later lines (last match wins) | move the specific rule below the general one |
| CO06 | medium: `!` negation, `[ ]` ranges (ignored by GitHub); low: GitLab section header | unsupported syntax | use a later more specific rule; empty owner list = unowned |
| CO07 | medium: one individual (not a team) is the effective owner of more than `max_individual_share` of files; low: files owned by a single individual and no team | bus factor | add a team or a second owner |
| CO08 | low | the same pattern appears twice; only the later line counts | merge the owners into one line |
| CO09 | medium | team has no members in your teams list | add members or use another team |
| CO10 | info | pattern with no owners: files intentionally unowned | fine if intended (generated code, vendored files) |
| CO11 | medium | files under a `require_owner` path are only owned by the catch-all `*` rule | add a specific rule for that path |
## Verdict
- **High findings**: fix before relying on "Require review from Code Owners".
- **Medium only**: the file works but some files go to the wrong people.
- **Healthy**: only low/info.
## Owners file (`--owners`, JSON)
| Key | Meaning |
|---|---|
| `org` | org name; teams from another org are flagged |
| `users` | logins with write access (without @) |
| `teams` | `{"org/slug": [members]}` |
| `require_owner` | patterns (same syntax) for paths that must have a specific owner |
| `max_individual_share` | share of files one person may own alone (default 0.4) |
| `ignore` | patterns for files to leave out (vendored code, lock files) |
## Limits of the check
- Repository permissions and team visibility are not checked - confirm with the `gh api` calls in the syntax reference or the "CODEOWNERS errors" banner.
- The file list is a snapshot of one branch; run it against the base branch of your pull requests.
FILE:templates/ownership-review.md
# CODEOWNERS fix: <repository>
**Branch:** <base branch> **Checked:** <YYYY-MM-DD> **Files:** <n> **Owned before / after:** <x% / y%>
## What was wrong
| Line | Rule | Problem | Effect |
|---|---|---|---|
| <11> | `<pattern> <owners>` | <unknown team @org/frontend-team> | <web files fall back to @org/platform> |
| <6> | `<pattern>` | <overridden by line 7> | <payments never review billing> |
## Changes
```diff
<- /services/api/billing/ @org/payments
< /services/api/ @org/backend
<+ /services/api/billing/ @org/payments>
```
## Who owns the key paths now
| Path | Before | After |
|---|---|---|
| <services/api/billing/invoices.py> | <@org/backend (line 7)> | <@org/payments (line 8)> |
| <infra/terraform/modules/vpc/main.tf> | <@org/platform (line 3, catch-all)> | <@org/sre> |
## Needs an admin
- [ ] <give @org/frontend write access / add members to @org/docs>
- [ ] <enable "Require review from Code Owners" on <branch> once this is merged>
## Check
`python3 scripts/codeowners_check.py .github/CODEOWNERS files.txt --owners owners.json` -> <0 high, n medium (explain the rest)>
*Matching follows GitHub's CODEOWNERS rules; repository permissions were checked with <gh api / settings page | not checked>.*
FILE:examples/CODEOWNERS
# CODEOWNERS for tidyq/tidyq-app (copied into .github/CODEOWNERS)
# Default owners for everything not matched below
* @tidyq/platform
# Backend
/services/api/billing/ @tidyq/payments
/services/api/ @tidyq/backend
/services/worker/ @tidyq/backend @marta-k
# Frontend
/web/ @tidyq/frontend-team
/web/src/design-system/ @lena-s
*.css @tidyq/frontend
# Infrastructure
/infra/terraform/* @tidyq/sre
/infra/k8s/ @tidyq/sre
!/infra/k8s/sandbox/
# CI and repository settings
/.github/workflows/ @tidyq/sre
/.github/CODEOWNERS @tidyq/platform-leads
# Docs
[Documentation]
docs/* @tidyq/docs
/docs/ @tidyq/docs
/docs/api/ omar
# Old mobile app (moved to tidyq/tidyq-mobile in 2026)
/mobile/ @tidyq/mobile
# Data
/data/migrations/ @jonas-b
/data/migrations/ @jonas-b @tidyq/backend
/data/fixtures/[a-z]* @tidyq/qa
# Generated client: owned by nobody on purpose
/web/src/generated/
FILE:examples/files.txt
.github/CODEOWNERS
.github/dependabot.yml
.github/workflows/ci.yml
.github/workflows/deploy.yml
.github/workflows/release.yml
README.md
package.json
docs/index.md
docs/getting-started.md
docs/api/endpoints.md
docs/api/auth.md
docs/runbooks/oncall.md
infra/terraform/main.tf
infra/terraform/variables.tf
infra/terraform/modules/vpc/main.tf
infra/terraform/modules/rds/main.tf
infra/k8s/api-deployment.yaml
infra/k8s/worker-deployment.yaml
infra/k8s/sandbox/debug-pod.yaml
services/api/app.py
services/api/routes/users.py
services/api/routes/orders.py
services/api/billing/invoices.py
services/api/billing/stripe_webhook.py
services/api/tests/test_routes.py
services/worker/main.py
services/worker/jobs/email.py
services/worker/jobs/reports.py
web/package.json
web/src/App.tsx
web/src/pages/Home.tsx
web/src/pages/Checkout.tsx
web/src/styles/main.css
web/src/design-system/Button.tsx
web/src/design-system/Modal.tsx
web/src/design-system/tokens.css
web/src/generated/api-client.ts
data/migrations/0041_add_orders.sql
data/migrations/0042_add_invoices.sql
data/fixtures/users.json
data/fixtures/orders.json
scripts/seed_db.py
scripts/rotate_keys.sh
FILE:examples/owners.json
{
"org": "tidyq",
"users": ["marta-k", "lena-s", "jonas-b", "omar-h", "priya-n", "sam-t"],
"teams": {
"tidyq/platform": ["priya-n", "sam-t"],
"tidyq/platform-leads": ["priya-n"],
"tidyq/backend": ["marta-k", "jonas-b", "omar-h"],
"tidyq/payments": ["marta-k"],
"tidyq/frontend": ["lena-s", "sam-t"],
"tidyq/sre": ["priya-n", "sam-t"],
"tidyq/docs": [],
"tidyq/qa": ["omar-h"]
},
"require_owner": [".github/", "infra/", "services/api/billing/", "data/migrations/"],
"max_individual_share": 0.25,
"ignore": []
}
FILE:examples/ownership-report.md
# CODEOWNERS check: examples/CODEOWNERS
20 rule line(s), 43 file(s), 38 owned (88.4%), 5 unowned.
**Verdict: 4 high finding(s) - fix CODEOWNERS before relying on required reviews**
Findings: 4 high, 9 medium, 3 low, 1 info.
Lines whose owners are all unknown (they still match, so their files get no code owner): 11, 31. Unknown owners are left out of the owner columns below.
## Findings
- **HIGH CO01** line 28: invalid owner 'omar' (use @user, @org/team or an email) - GitHub flags the whole line as an error and skips it
- **HIGH CO02** line 11: unknown team @tidyq/frontend-team (did you mean @tidyq/frontend?) - GitHub will not request reviews from it
- **HIGH CO02** line 11: no valid owner left - its 4 file(s) get no code owner (they do not fall back to earlier lines)
- **HIGH CO02** line 31: unknown team @tidyq/mobile - GitHub will not request reviews from it
- **MEDIUM CO03** 4 file(s) have no code owner (no matching rule, or only unknown owners): web/package.json, web/src/App.tsx, web/src/pages/Home.tsx, web/src/pages/Checkout.tsx
- **MEDIUM CO04** line 31: '/mobile/' matches no file - path renamed or removed?
- **MEDIUM CO05** line 6: '/services/api/billing/' (2 file(s)) is fully overridden by later line(s) 7 - last match wins, put specific rules after general ones
- **MEDIUM CO05** line 26: 'docs/*' (2 file(s)) is fully overridden by later line(s) 27 - last match wins, put specific rules after general ones
- **MEDIUM CO05** line 34: '/data/migrations/' (2 file(s)) is fully overridden by later line(s) 35 - last match wins, put specific rules after general ones
- **MEDIUM CO06** line 18: '!/infra/k8s/sandbox/' uses ! negation, which CODEOWNERS does not support - GitHub ignores the line
- **MEDIUM CO06** line 36: '/data/fixtures/[a-z]*' uses [ ] character ranges, which CODEOWNERS does not support - GitHub ignores the line
- **MEDIUM CO09** line 26: team @tidyq/docs has no members - review requests go nowhere
- **MEDIUM CO11** 3 file(s) under paths that must have an owner only fall back to the catch-all '*' rule (no specific owner): .github/dependabot.yml, infra/terraform/modules/vpc/main.tf, infra/terraform/modules/rds/main.tf
- **LOW CO06** line 25: '[Documentation]' is a GitLab section header; GitHub treats it as an invalid line
- **LOW CO07** 2 file(s) have a single individual owner and no team (e.g. web/src/design-system/Button.tsx, web/src/design-system/Modal.tsx)
- **LOW CO08** line 35 repeats the pattern '/data/migrations/' of line 34 - only the later line counts
- **INFO CO10** line 39: '/web/src/generated/' has no owners - its 1 file(s) are intentionally unowned
## Coverage by top-level directory
| Directory | Files | Owned | Unowned | Effective owners |
|---|---|---|---|---|
| (root) | 2 | 2 | 0 | @tidyq/platform (2) |
| .github/ | 5 | 5 | 0 | @tidyq/sre (3), @tidyq/platform-leads (1), @tidyq/platform (1) |
| data/ | 4 | 4 | 0 | @jonas-b (2), @tidyq/backend (2), @tidyq/platform (2) |
| docs/ | 5 | 5 | 0 | @tidyq/docs (5) |
| infra/ | 7 | 7 | 0 | @tidyq/sre (5), @tidyq/platform (2) |
| scripts/ | 2 | 2 | 0 | @tidyq/platform (2) |
| services/ | 9 | 9 | 0 | @tidyq/backend (9), @marta-k (3) |
| web/ | 9 | 4 | 5 | @tidyq/frontend (2), @lena-s (2) |
## Rules
| Line | Pattern | Owners | Files matched | Effective for | Note |
|---|---|---|---|---|---|
| 3 | `*` | @tidyq/platform | 43 | 9 | |
| 6 | `/services/api/billing/` | @tidyq/payments | 2 | 0 | CO05 |
| 7 | `/services/api/` | @tidyq/backend | 6 | 6 | |
| 8 | `/services/worker/` | @tidyq/backend @marta-k | 3 | 3 | |
| 11 | `/web/` | @tidyq/frontend-team | 9 | 4 | CO02 |
| 12 | `/web/src/design-system/` | @lena-s | 3 | 2 | |
| 13 | `*.css` | @tidyq/frontend | 2 | 2 | |
| 16 | `/infra/terraform/*` | @tidyq/sre | 2 | 2 | |
| 17 | `/infra/k8s/` | @tidyq/sre | 3 | 3 | |
| 18 | `!/infra/k8s/sandbox/` | (none) | - | - | CO06 |
| 21 | `/.github/workflows/` | @tidyq/sre | 3 | 3 | |
| 22 | `/.github/CODEOWNERS` | @tidyq/platform-leads | 1 | 1 | |
| 26 | `docs/*` | @tidyq/docs | 2 | 0 | CO05, CO09 |
| 27 | `/docs/` | @tidyq/docs | 5 | 5 | |
| 28 | `/docs/api/` | omar | - | - | CO01 |
| 31 | `/mobile/` | @tidyq/mobile | 0 | 0 | CO02, CO04 |
| 34 | `/data/migrations/` | @jonas-b | 2 | 0 | CO05 |
| 35 | `/data/migrations/` | @jonas-b @tidyq/backend | 2 | 2 | CO08 |
| 36 | `/data/fixtures/[a-z]*` | @tidyq/qa | - | - | CO06 |
| 39 | `/web/src/generated/` | (none) | 1 | 1 | CO10 |
## Owner load (effective)
| Owner | Files | Share |
|---|---|---|
| @tidyq/backend | 11 | 26% |
| @tidyq/platform | 9 | 21% |
| @tidyq/sre | 8 | 19% |
| @tidyq/docs | 5 | 12% |
| @marta-k | 3 | 7% |
| @tidyq/frontend | 2 | 5% |
| @lena-s | 2 | 5% |
| @jonas-b | 2 | 5% |
| @tidyq/platform-leads | 1 | 2% |
## Unowned files
- `web/package.json` (line 11: only unknown owners)
- `web/src/App.tsx` (line 11: only unknown owners)
- `web/src/pages/Home.tsx` (line 11: only unknown owners)
- `web/src/pages/Checkout.tsx` (line 11: only unknown owners)
- `web/src/generated/api-client.ts` (explicitly unowned, line 39)
## Who owns
- services/api/billing/invoices.py: @tidyq/backend (line 7: /services/api/)
- infra/terraform/modules/vpc/main.tf: @tidyq/platform (line 3: *)
- web/src/pages/Checkout.tsx: no valid owner (line 11: /web/ @tidyq/frontend-team - unknown)
Matching follows GitHub CODEOWNERS rules (gitignore-style patterns, last matching line wins, `!` and `[ ]` unsupported). Owners are checked against your owners file only; confirm write access in the repository settings.
FILE:scripts/codeowners_check.py
#!/usr/bin/env python3
"""CODEOWNERS coverage and ownership checker (stdlib only, Python 3.8+).
Reads a GitHub CODEOWNERS file and the repository file list (`git ls-files` output), works out
the effective owner of every file with GitHub's rules (gitignore-style patterns, the LAST
matching line wins) and reports syntax errors, unknown owners, unowned files (also under paths
that must have an owner), rules that match nothing, rules that are fully overridden by later
lines, unsupported syntax, duplicates, empty teams and owner concentration.
Usage:
codeowners_check.py CODEOWNERS FILES.txt [--owners OWNERS.json] [--who PATH]... [--json]
codeowners_check.py --self-test
Exit: 0 = no high findings, 1 = high findings, 2 = bad input.
"""
import argparse, json, re, sys
from collections import Counter, OrderedDict
SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
USER_RE = re.compile(r"^@[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$")
TEAM_RE = re.compile(r"^@([A-Za-z0-9][A-Za-z0-9-]*)/([A-Za-z0-9][A-Za-z0-9._-]*)$")
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
class InputError(Exception):
pass
# ---------------------------------------------------------------- patterns
def pattern_regex(pat):
"""Return (compiled regex, dir_only, direct_children_only) for a CODEOWNERS pattern."""
dir_only = pat.endswith("/")
p = pat.rstrip("/")
anchored = p.startswith("/") or "/" in p
p = p.lstrip("/")
direct_only = p.endswith("/*")
out, i = "", 0
while i < len(p):
if p.startswith("**/", i):
out += "(?:.*/)?"; i += 3
elif p.startswith("/**", i) and i + 3 == len(p):
out += "/.*"; i += 3
elif p.startswith("**", i):
out += ".*"; i += 2
elif p[i] == "*":
out += "[^/]*"; i += 1
elif p[i] == "?":
out += "[^/]"; i += 1
elif p[i] == "\\" and i + 1 < len(p):
out += re.escape(p[i + 1]); i += 2
else:
out += re.escape(p[i]); i += 1
if not anchored:
out = "(?:.*/)?" + out
return re.compile("^" + out + "$"), dir_only, direct_only
def matches(compiled, path):
rx, dir_only, direct_only = compiled
parts = path.split("/")
if not dir_only and rx.match(path):
return True
if direct_only:
return False
for k in range(1, len(parts)): # a matching directory owns everything under it
if rx.match("/".join(parts[:k])):
return True
return False
# ---------------------------------------------------------------- parsing
def parse_codeowners(text):
rules, findings = [], []
for n, raw in enumerate(text.splitlines(), 1):
line = raw.strip()
if not line or line.startswith("#"):
continue
if re.match(r"^\^?\[[^\]]+\](\[\d+\])?(\s+@.*)?$", line):
findings.append(("low", "CO06", "line %d: '%s' is a GitLab section header; GitHub treats it as an invalid line" % (n, line), n))
continue
tokens = re.split(r"(?<!\\)\s+", line)
pat, owners = tokens[0], []
for t in tokens[1:]:
if t.startswith("#"):
break
owners.append(t)
rule = {"line": n, "pattern": pat, "owners": owners, "valid": True}
if pat.startswith("!"):
findings.append(("medium", "CO06", "line %d: '%s' uses ! negation, which CODEOWNERS does not support - GitHub ignores the line" % (n, pat), n))
rule["valid"] = False
elif "[" in pat or "]" in pat:
findings.append(("medium", "CO06", "line %d: '%s' uses [ ] character ranges, which CODEOWNERS does not support - GitHub ignores the line" % (n, pat), n))
rule["valid"] = False
bad = [o for o in owners if not (USER_RE.match(o) or TEAM_RE.match(o) or EMAIL_RE.match(o))]
if bad:
findings.append(("high", "CO01", "line %d: invalid owner %s (use @user, @org/team or an email) - GitHub flags the whole line as an error and skips it" % (n, ", ".join("'%s'" % b for b in bad)), n))
rule["valid"] = False
if rule["valid"]:
try:
rule["rx"] = pattern_regex(pat)
except re.error as e:
findings.append(("high", "CO01", "line %d: pattern '%s' cannot be parsed (%s)" % (n, pat, e), n))
rule["valid"] = False
rules.append(rule)
return rules, findings
def load_files(text):
files = []
for l in text.splitlines():
l = l.strip()
if l and not l.startswith("#"):
files.append(l[2:] if l.startswith("./") else l.lstrip("/"))
if not files:
raise InputError("file list is empty (use: git ls-files > files.txt)")
return files
# ---------------------------------------------------------------- analysis
def analyse(co_text, files_text, owners_cfg=None):
owners_cfg = owners_cfg or {}
allowed_keys = {"org", "users", "teams", "require_owner", "max_individual_share", "ignore"}
unknown = set(owners_cfg) - allowed_keys
if unknown:
raise InputError("unknown key(s) in owners file: %s" % ", ".join(sorted(unknown)))
rules, findings = parse_codeowners(co_text)
if not rules:
raise InputError("CODEOWNERS has no rules")
files = load_files(files_text)
ignore = [pattern_regex(p) for p in owners_cfg.get("ignore", [])]
files = [f for f in files if not any(matches(c, f) for c in ignore)]
valid = [r for r in rules if r["valid"]]
eff, matched_by = {}, {r["line"]: [] for r in valid}
for f in files:
last = None
for r in valid:
if matches(r["rx"], f):
matched_by[r["line"]].append(f)
last = r
eff[f] = last
effective = Counter(r["line"] for r in eff.values() if r)
# known owners
org = owners_cfg.get("org")
users = {u.lower().lstrip("@") for u in owners_cfg.get("users", [])}
teams = {k.lower().lstrip("@"): v for k, v in owners_cfg.get("teams", {}).items()}
have_list = bool(users or teams)
reported, unknown_owners = set(), set()
for r in valid:
for o in r["owners"]:
key = o.lower()
m = TEAM_RE.match(o)
problem = None
if m and have_list:
name = (m.group(1) + "/" + m.group(2)).lower()
if org and m.group(1).lower() != org.lower():
problem = "team %s is not in org @%s" % (o, org)
elif name not in teams:
close = [t for t in teams if t.split("/")[-1].startswith(m.group(2).lower()[:4])]
problem = "unknown team %s%s" % (o, (" (did you mean @%s?)" % close[0]) if close else "")
elif not teams[name] and key not in reported:
findings.append(("medium", "CO09", "line %d: team %s has no members - review requests go nowhere" % (r["line"], o), r["line"])); reported.add(key)
elif USER_RE.match(o) and have_list and key.lstrip("@") not in users:
problem = "unknown user %s (not in the owners list / no write access)" % o
if problem:
unknown_owners.add(key)
if key not in reported:
findings.append(("high", "CO02", "line %d: %s - GitHub will not request reviews from it" % (r["line"], problem), r["line"])); reported.add(key)
for r in rules:
r["ok"] = [o for o in r["owners"] if o.lower() not in unknown_owners]
bad_lines = sorted(r["line"] for r in valid if r["owners"] and not r["ok"])
for ln in [x for x in bad_lines if effective[x]]:
findings.append(("high", "CO02", "line %d: no valid owner left - its %d file(s) get no code owner (they do not fall back to earlier lines)" % (ln, effective[ln]), ln))
# dead, shadowed, duplicate rules
seen = {}
for r in valid:
if r["pattern"] in seen:
findings.append(("low", "CO08", "line %d repeats the pattern '%s' of line %d - only the later line counts" % (r["line"], r["pattern"], seen[r["pattern"]]), r["line"]))
seen[r["pattern"]] = r["line"]
if not matched_by[r["line"]]:
findings.append(("medium", "CO04", "line %d: '%s' matches no file - path renamed or removed?" % (r["line"], r["pattern"]), r["line"]))
elif effective[r["line"]] == 0:
later = sorted({eff[f]["line"] for f in matched_by[r["line"]] if eff[f]})
findings.append(("medium", "CO05", "line %d: '%s' (%d file(s)) is fully overridden by later line(s) %s - last match wins, put specific rules after general ones" % (r["line"], r["pattern"], len(matched_by[r["line"]]), ", ".join(map(str, later))), r["line"]))
if not r["owners"]:
findings.append(("info", "CO10", "line %d: '%s' has no owners - its %d file(s) are intentionally unowned" % (r["line"], r["pattern"], effective[r["line"]]), r["line"]))
# coverage
unowned = [f for f in files if not eff[f] or not eff[f]["ok"]]
no_rule = [f for f in unowned if not eff[f]]
req = [(p, pattern_regex(p)) for p in owners_cfg.get("require_owner", [])]
crit = [f for f in unowned if any(matches(c, f) for _, c in req)]
if crit:
findings.append(("high", "CO03", "%d file(s) under paths that must have an owner are unowned: %s" % (len(crit), ", ".join(crit[:6]) + (" ..." if len(crit) > 6 else "")), 0))
catch_all = [f for f in files if eff[f] and eff[f]["ok"] and eff[f]["pattern"] in ("*", "/*", "**", "/**")
and any(matches(c, f) for _, c in req)]
if catch_all:
findings.append(("medium", "CO11", "%d file(s) under paths that must have an owner only fall back to the catch-all '%s' rule (no specific owner): %s" % (len(catch_all), eff[catch_all[0]]["pattern"], ", ".join(catch_all[:6]) + (" ..." if len(catch_all) > 6 else "")), 0))
rest = [f for f in unowned if f not in crit and (not eff[f] or eff[f]["owners"])]
if rest:
findings.append(("medium", "CO03", "%d file(s) have no code owner (no matching rule, or only unknown owners): %s" % (len(rest), ", ".join(rest[:6]) + (" ..." if len(rest) > 6 else "")), 0))
# owner load
load = Counter()
for f in files:
if eff[f]:
for o in eff[f]["ok"]:
load[o] += 1
share = float(owners_cfg.get("max_individual_share", 0.4))
for o, n in load.most_common():
if USER_RE.match(o) and n / len(files) > share:
findings.append(("medium", "CO07", "%s is the owner of %d of %d files (%.0f%%) as an individual - bus factor; add a team or a second owner" % (o, n, len(files), 100.0 * n / len(files)), 0))
solo = [f for f in files if eff[f] and len(eff[f]["ok"]) == 1 and USER_RE.match(eff[f]["ok"][0])]
if solo:
findings.append(("low", "CO07", "%d file(s) have a single individual owner and no team (e.g. %s)" % (len(solo), ", ".join(solo[:3])), 0))
findings.sort(key=lambda f: (SEV[f[0]], f[1], f[3]))
return {"rules": rules, "files": files, "eff": eff, "matched_by": matched_by, "effective": effective,
"unowned": unowned, "load": load, "findings": findings, "no_valid_owner_lines": bad_lines}
def who(res, path):
r = res["eff"].get(path)
if path not in res["eff"]:
rx_hits = [x for x in res["rules"] if x["valid"] and matches(x["rx"], path)]
r = rx_hits[-1] if rx_hits else None
if not r:
return "%s: no owner (no matching rule)" % path
if r["owners"] and not r["ok"]:
return "%s: no valid owner (line %d: %s %s - unknown)" % (path, r["line"], r["pattern"], " ".join(r["owners"]))
return "%s: %s (line %d: %s)" % (path, " ".join(r["ok"]) or "explicitly unowned", r["line"], r["pattern"])
# ---------------------------------------------------------------- report
def report(res, title, whos=()):
f, files = res["findings"], res["files"]
cnt = Counter(x[0] for x in f)
owned = len(files) - len(res["unowned"])
L = ["# CODEOWNERS check: %s" % title, ""]
L.append("%d rule line(s), %d file(s), %d owned (%.1f%%), %d unowned." % (len(res["rules"]), len(files), owned, 100.0 * owned / len(files), len(res["unowned"])))
L.append("")
L.append("**Verdict: %s**" % ("%d high finding(s) - fix CODEOWNERS before relying on required reviews" % cnt["high"] if cnt["high"] else ("review the medium findings" if cnt["medium"] else "CODEOWNERS looks healthy")))
L.append("")
L.append("Findings: %d high, %d medium, %d low, %d info." % (cnt["high"], cnt["medium"], cnt["low"], cnt["info"]))
L.append("")
if res["no_valid_owner_lines"]:
L.append("Lines whose owners are all unknown (they still match, so their files get no code owner): %s. Unknown owners are left out of the owner columns below." % ", ".join(map(str, res["no_valid_owner_lines"])))
L.append("")
L += ["## Findings", ""]
for s, c, msg, _ in f:
L.append("- **%s %s** %s" % (s.upper(), c, msg))
L += ["", "## Coverage by top-level directory", "", "| Directory | Files | Owned | Unowned | Effective owners |", "|---|---|---|---|---|"]
dirs = OrderedDict()
for p in files:
d = p.split("/")[0] + "/" if "/" in p else "(root)"
dirs.setdefault(d, []).append(p)
for d, ps in sorted(dirs.items()):
un = [p for p in ps if p in res["unowned"]]
own = Counter(o for p in ps if res["eff"][p] for o in res["eff"][p]["ok"])
L.append("| %s | %d | %d | %d | %s |" % (d, len(ps), len(ps) - len(un), len(un), ", ".join("%s (%d)" % kv for kv in own.most_common()) or "-"))
L += ["", "## Rules", "", "| Line | Pattern | Owners | Files matched | Effective for | Note |", "|---|---|---|---|---|---|"]
notes = {}
for s, c, msg, ln in f:
if ln:
notes.setdefault(ln, []).append(c)
for r in res["rules"]:
m = len(res["matched_by"].get(r["line"], [])) if r["valid"] else "-"
e = res["effective"].get(r["line"], 0) if r["valid"] else "-"
L.append("| %d | `%s` | %s | %s | %s | %s |" % (r["line"], r["pattern"], " ".join(r["owners"]) or "(none)", m, e, ", ".join(sorted(set(notes.get(r["line"], [])))) or ""))
L += ["", "## Owner load (effective)", "", "| Owner | Files | Share |", "|---|---|---|"]
for o, n in res["load"].most_common():
L.append("| %s | %d | %.0f%% |" % (o, n, 100.0 * n / len(files)))
if res["unowned"]:
L += ["", "## Unowned files", ""]
for p in res["unowned"]:
e = res["eff"][p]
L.append("- `%s`%s" % (p, "" if not e else (" (explicitly unowned, line %d)" % e["line"] if not e["owners"] else " (line %d: only unknown owners)" % e["line"])))
if whos:
L += ["", "## Who owns", ""]
for p in whos:
L.append("- " + who(res, p))
L += ["", "Matching follows GitHub CODEOWNERS rules (gitignore-style patterns, last matching line wins, `!` and `[ ]` unsupported). Owners are checked against your owners file only; confirm write access in the repository settings."]
return "\n".join(L) + "\n"
def to_json(res):
return {"files": len(res["files"]), "unowned": res["unowned"], "no_valid_owner_lines": res["no_valid_owner_lines"],
"owners": {p: (res["eff"][p]["ok"] if res["eff"][p] else []) for p in res["files"]},
"rules": [{"line": r["line"], "pattern": r["pattern"], "owners": r["owners"], "valid": r["valid"],
"matched": len(res["matched_by"].get(r["line"], [])), "effective": res["effective"].get(r["line"], 0)} for r in res["rules"]],
"load": dict(res["load"]),
"findings": [{"severity": s, "code": c, "message": m, "line": ln} for s, c, m, ln in res["findings"]]}
# ---------------------------------------------------------------- self-test
def self_test():
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
def owner(co, f, path, cfg=None):
r = analyse(co, f, cfg)
e = r["eff"][path]
return e["ok"] if e else None
def codes(co, f, cfg=None):
return [(x[0], x[1]) for x in analyse(co, f, cfg)["findings"]]
F = "README.md\nsrc/app.py\nsrc/api/routes.py\nsrc/api/v2/users.py\ndocs/index.md\ndocs/guide/setup.md\nweb/app.js\nweb/lib/util.js\n.github/workflows/ci.yml\n"
t("* matches everything", owner("* @a", F, "src/api/v2/users.py") == ["@a"])
t("last matching line wins", owner("* @a\n/src/ @b", F, "src/app.py") == ["@b"] and owner("* @a\n/src/ @b", F, "README.md") == ["@a"])
t("*.js matches at any depth", owner("*.js @js", F, "web/lib/util.js") == ["@js"])
t("/docs/ owns nested files", owner("/docs/ @d", F, "docs/guide/setup.md") == ["@d"])
t("docs/* does not own nested files", owner("docs/* @d", F, "docs/guide/setup.md") is None and owner("docs/* @d", F, "docs/index.md") == ["@d"])
t("unanchored dir name 'api/' matches anywhere", owner("api/ @x", F, "src/api/v2/users.py") == ["@x"])
t("** in the middle", owner("/src/**/users.py @u", F, "src/api/v2/users.py") == ["@u"])
t("anchored /README.md only at root", owner("/README.md @r", "README.md\nsub/README.md\n", "sub/README.md") is None)
t("? matches one character", owner("src/ap?.py @q", F, "src/app.py") == ["@q"])
t("no owners = explicitly unowned", owner("* @a\n/web/", F, "web/app.js") == [] and ("info", "CO10") in codes("* @a\n/web/", F))
t("invalid owner -> CO01 high, line skipped", ("high", "CO01") in codes("* @a\n/src/ alice", F) and owner("* @a\n/src/ alice", F, "src/app.py") == ["@a"])
t("negation -> CO06 medium", ("medium", "CO06") in codes("* @a\n!/src/", F))
t("[ ] range -> CO06 medium", ("medium", "CO06") in codes("* @a\n/src/[ab]* @b", F))
t("GitLab section -> CO06 low", ("low", "CO06") in codes("[Docs]\n* @a", F))
cfg = {"org": "acme", "users": ["a", "b"], "teams": {"acme/web": ["a"], "acme/empty": []}}
t("unknown team -> CO02 high, files get no owner (no fallback)", ("high", "CO02") in codes("* @a\n/web/ @acme/webb", F, cfg) and owner("* @a\n/web/ @acme/webb", F, "web/app.js", cfg) == [] and ("medium", "CO03") in codes("* @a\n/web/ @acme/webb", F, cfg))
t("unknown owner dropped, valid co-owner kept", owner("* @a\n/web/ @acme/webb @b", F, "web/app.js", cfg) == ["@b"])
t("team of other org -> CO02", ("high", "CO02") in codes("* @other/web", F, cfg))
t("unknown user -> CO02", ("high", "CO02") in codes("* @zed", F, cfg))
t("empty team -> CO09", ("medium", "CO09") in codes("* @a\n/web/ @acme/empty", F, cfg))
t("email owner accepted", ("high", "CO01") not in codes("* dev@acme.io", F))
t("dead rule -> CO04", ("medium", "CO04") in codes("* @a\n/legacy/ @b", F))
t("shadowed rule -> CO05", ("medium", "CO05") in codes("/src/api/ @b\n/src/ @a", F))
t("partially overridden is not CO05", ("medium", "CO05") not in codes("/src/ @a\n/src/api/ @b", F))
t("duplicate pattern -> CO08", ("low", "CO08") in codes("* @a\n/src/ @b\n/src/ @c", F))
t("unowned file -> CO03 medium", ("medium", "CO03") in codes("/src/ @a", F))
t("require_owner path unowned -> CO03 high", ("high", "CO03") in codes("/src/ @acme/web", F, dict(cfg, require_owner=[".github/"])))
t("required path only owned by '*' -> CO11", ("medium", "CO11") in codes("* @a\n/src/ @b", F, {"require_owner": [".github/"]}))
t("dead rule with unknown owner still -> CO04", ("medium", "CO04") in codes("* @a\n/legacy/ @acme/zz", F, cfg))
t("individual owner concentration -> CO07", ("medium", "CO07") in codes("* @a", F, {"max_individual_share": 0.5}))
t("ignore list removes files", len(analyse("* @a", F, {"ignore": ["docs/"]})["files"]) == 7)
r = analyse("* @a\n/src/ @b", F)
t("--who reports line", who(r, "src/api/routes.py") == "src/api/routes.py: @b (line 2: /src/)")
t("./ prefix stripped, .github kept", analyse("* @a", "./.github/x.yml\n")["files"] == [".github/x.yml"])
t("report has verdict and coverage table", "**Verdict:" in report(r, "t") and "## Coverage by top-level directory" in report(r, "t"))
try:
analyse("* @a", F, {"orgs": "x"}); ok = False
except InputError:
ok = True
t("unknown owners-file key rejected", ok)
for name, ok in tests:
print(("PASS " if ok else "FAIL ") + name)
passed = sum(ok for _, ok in tests)
print("%d/%d passed" % (passed, len(tests)))
return 0 if passed == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Check a GitHub CODEOWNERS file against the repository file list.")
ap.add_argument("codeowners", nargs="?")
ap.add_argument("files", nargs="?", help="file list, e.g. git ls-files > files.txt")
ap.add_argument("--owners", help="JSON with org, users, teams, require_owner, max_individual_share, ignore")
ap.add_argument("--who", action="append", default=[], help="show the effective owner of a path (repeatable)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.codeowners or not a.files:
ap.print_usage(sys.stderr)
return 2
try:
co = open(a.codeowners, encoding="utf-8").read()
fl = open(a.files, encoding="utf-8").read()
cfg = None
if a.owners:
try:
cfg = json.load(open(a.owners, encoding="utf-8"))
except json.JSONDecodeError as e:
raise InputError("owners file is not valid JSON: %s" % e)
res = analyse(co, fl, cfg)
except (OSError, InputError) as e:
print("error: %s" % e, file=sys.stderr)
return 2
if a.json:
print(json.dumps(to_json(res), indent=2))
else:
sys.stdout.write(report(res, a.codeowners, a.who))
return 1 if any(x[0] == "high" for x in res["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Recently Updated

A short fill-in-the-blanks image prompt for charming paper cut-out storybook characters: name any subject and a simple action and get a full-body character built from layered handmade paper shapes with visible cut edges, soft shadows between layers, flat colorful paper tones and a gentle stop-motion, folk-tale feel on a clean white background. Great for children's book spreads, greeting cards and stickers.
Paper cut-out animation of a little brown bear in a patchwork apron watering a tall sunflower, full-body character, layered paper shapes, handmade paper texture, visible cut edges, soft shadows between layers, flat colorful paper tones, slightly imperfect handcrafted details, minimal paper-collage scenery, charming folk story aesthetic, gentle stop-motion feel, poetic children’s book atmosphere, clean white background
A production-grade 15-second, four-shot video prompt: an old seaman rows alone through dense dawn fog on cold northern waters until a 140-meter three-masted black-sailed galleon slowly emerges ahead, revealed in layers from a side close-up to two wide shots built to sell its enormous scale. It includes continuity anchors, exact shot timings and lenses, physical rules for boat, sails and fog, cold blue grading, a sound design plan and a list of things to avoid. No battle, no ghosts, no dialogue.
[Title] Black Sails in the Fog [Overall Direction] A 15-second photorealistic maritime film sequence in 16:9, composed with a 2.39:1 widescreen sensibility and natural 24fps motion. The sequence contains four distinct shots. The story takes place in cold northern European waters during the medieval period. An elderly white European man rows a small wooden boat alone into dense fog, where he discovers a black-sailed pirate ship far larger than any ordinary vessel. The sequence focuses on the ship being revealed gradually through the fog and on the extreme difference in scale between the fragile rowboat, the elderly man, and the enormous pirate ship. The atmosphere is cold, silent, and oppressive, but there are no supernatural creatures, ghosts, magical effects, or battles. All timber, canvas, ropes, seawater, fog, and human movement must have convincing weight and physical behavior. The footage should feel captured with a full-scale practical sailing ship, a real ocean location, and a cinema camera—not like a video-game cutscene or fantasy concept art. [Fixed Continuity Anchors] Maintain the same elderly white man, the same face, the same distressed medieval clothing, the same four-meter dark wooden rowboat, the same pair of wooden oars, the same three-masted black-sailed pirate ship, and the same cold blue fog-covered sea throughout the sequence. The pirate ship must remain ahead of the old man and slightly to the right. Its bow faces the rowboat at a slight angle. The ship is almost motionless, moving only slowly with the long ocean swell. The rowboat continues moving toward the pirate ship. Wind, waves, and fog consistently move from the left side of the frame toward the right. Cold morning light always comes from the upper left. The pirate ship must retain exactly three main masts, layered black sails, a high forecastle and sterncastle, a long bowsprit, a dark wooden hull, and dense rigging in every shot. Its design must not change between cuts. [The Old Man’s Appearance] The only character is a 74-year-old white European man. He is thin and slightly stooped, but his shoulders and arms retain the wiry strength of a lifetime of physical labor. His pale, coarse skin is deeply marked by cold wind, saltwater, and years of sun exposure. He has prominent cheekbones, a high and slightly crooked nose, deep-set eyes, and an uneven gray-white beard covering his cheeks and chin. His eyes are gray-blue, naturally moist, with small living catchlights. His forehead, eye corners, and mouth carry authentic age lines. His gray-white hair reaches the back of his neck, dampened by sea fog and loosely tied with an old leather cord. Several wet strands cling to his temples and ears. His skin must retain pores, age spots, and wrinkles. Do not beautify, smooth, or artificially dirty his face. [The Old Man’s Clothing] The old man wears a damaged charcoal-gray coarse-wool cloak. Its edges are frayed and repaired with a few simple hand-sewn patches. Moisture from the sea fog has darkened the fabric. Beneath the cloak is a dirty gray-white linen tunic with a loose collar and worn cuffs. A weathered dark-brown leather belt secures the clothing at his waist. He wears dark gray-brown woven trousers and cracked old leather boots. A short length of faded dark reddish-brown wool is tied around his neck. This scarf is the only faint warm accent in the otherwise cold blue image. The clothing must contain no modern stitching, zippers, plastic, branding, reflective strips, or modern flotation equipment. [Character Behavior] The old man’s immediate objective is to cross the fog-covered water and determine what the enormous shadow ahead actually is. His obstacles are the dense fog and the long ocean swell that repeatedly alters the rowboat’s direction. He rows with the practiced economy of an aging seaman. His center of gravity remains low, his feet planted firmly against the bottom of the boat. His lower back moves first, leaning slowly backward; his shoulders and arms then pull the two wooden oars toward his chest. The strength of his left and right arms differs slightly. Each stroke is accompanied by natural breathing, shoulder-blade movement, friction from the oarlocks, and the forward inertia of the boat. His movement is no longer light, but it remains experienced and efficient. When the deep groan of a massive wooden hull reaches him through the fog, his ongoing rowing stroke slows. The left oar blade remains submerged and creates resistance while the right oar stops in midair. Seawater drips continuously from its edge. His eyes leave the surface of the sea first and move toward the shadow above. He gives one slow blink. Only then does his chin rise and his head follow the direction of his gaze. His lips remain closed, his breathing pauses briefly, and his hands tighten around the oar handles. He does not shout or flee. After confirming that an enormous vessel is hidden in the fog, he adjusts his seated posture, braces both feet, and resumes rowing slowly toward the shadow. His performance conveys caution, exhaustion, and an irresistible need to know—not theatrical fear. [The Rowboat] A medieval wooden rowboat approximately 4.2 meters long, constructed from dark, aged oak. The plank seams and wooden fastening pegs are structurally believable. Most of the black waterproof tar coating has worn away, revealing gray-brown wood grain beneath. The gunwales show wear from years of use. The interior remains sparse, containing only one coil of old hemp rope. Do not add fishing nets, lanterns, barrels, crates, or decorative objects. The two wooden oars must retain the same dimensions, color, material, and position throughout the sequence. The boat rises, falls, and rolls gently with long ocean swells approximately one to one-and-a-half meters high. It must have convincing weight and inertia. Its bow creates a small amount of dark spray as it cuts through the water. It must not float or bounce like a lightweight toy. [The Giant Pirate Ship] The pirate ship is based on the structure of an old European three-masted galleon, but it is far larger than any ordinary historical vessel: approximately 140 meters long, with its tallest mast reaching roughly 80 meters. Its scale is extraordinary, but its timber construction, structural loading, rigging connections, and canvas movement must remain physically believable. The hull is built from almost black, heavily weathered oak. Its timbers are wide and heavy, with visible plank seams and matte iron reinforcement along structural edges. The bow is tall and projects forward with oppressive weight, ending in a long, narrow bowsprit. The stern rises into a multi-level sterncastle. Two rows of dark gunports line the sides of the hull, but no cannons are fired. Salt deposits, dark wet marks, and small amounts of sea growth appear around the waterline. The ship is not rotten and does not glow. Three tall main masts carry multiple levels of square black sails. The canvas is heavy and aged, damaged by wind and salt, with naturally torn edges and visible repairs. The sails are not pure black; they contain subtle layers of deep blue-gray, charcoal, and weathered brown. Dense hemp rigging connects the masts, yards, gunwales, and corners of the sails according to authentic sailing-ship construction. A small faded black flag flies from the highest mast. It bears only a simple, weathered bone-white skull-and-crossbones symbol with no written words. No crew members, torches, or artificial lights are visible on deck. The vessel feels unnaturally quiet but remains completely physical and opaque, never transparent or ghostlike. [Ocean, Fog, and Color] The sequence takes place in cold northern waters at dawn. The boundary between sea and sky is nearly erased by thick blue-gray marine fog. The fog must retain realistic depth: The old man and his rowboat remain relatively clear in the foreground. Water several dozen meters away loses contrast. Only portions of the pirate ship are visible in the middle distance. The upper masts and corners of the sails disappear back into the fog. The seawater consists of low-saturation slate blue, deep blue-gray, and faint gray-green tones. Wave crests do not produce unnaturally bright white foam. The sky is pale cold blue mixed with pearl gray. The pirate ship is near-black wood with deep blue-gray sails. The old man’s faded reddish-brown scarf provides the only very weak warm accent. Wind slowly pushes the fog across the ship, allowing its fixed structure to appear in layers. The fog changes what is visible; the vessel itself never changes shape. The fog must not behave like fast-moving smoke, and it must not make the entire image uniformly blurry. [Shot One | 0–3.5 Seconds | Side Close-Up of the Old Man] A 65mm cinema lens. The camera floats beside the left side of the rowboat, slightly below the old man’s shoulder height, rising and falling gently with the same ocean swell. The old man’s side profile occupies the left half of the frame. The background contains only defocused cold-blue seawater and layered fog. The pirate ship must not appear yet. The old man is already midway through a rowing stroke. His lower back moves slowly backward as both hands pull the oars toward his body. Damp gray hair clings to his face, and the coarse wool cloak responds subtly to the sea wind. A broad swell lifts the wooden boat, and the camera rises with it in a restrained, physically grounded movement. One distant, low note from an old bronze ship’s bell travels through the fog, followed by the deep groan of an enormous timber structure under pressure from the sea. The old man’s rowing motion gradually stops. One oar blade remains submerged while the other pauses in midair. Water drips from the edge of the blade. His eyes move toward the upper-right side of the frame first. He gives one slow blink, then gradually raises his head. Cut directly before the head movement is fully completed. Do not reveal what he sees in this shot. [Shot Two | 3.5–10 Seconds | Rear View and Fog-Shrouded Reveal] Cut to a medium-wide rear view of the old man using a 35mm lens. The camera is positioned close to the stern of the rowboat at approximately shoulder height. Preserve the direction of the boat, the old man’s seated position, the direction of the waves, and the direction of the cold light from the previous shot. The old man occupies the lower center of the frame with his back toward the camera. An enormous dark shape rises inside the fog ahead. At first, only the towering bow and one vague vertical mast line can be distinguished. As the fog moves laterally, three enormous masts, layered black sails, dense rigging, and elevated deck structures gradually become visible. The tops of the masts remain hidden. The pirate ship must not appear completely in a single instant. Its structure remains fixed; only its visibility changes as fog passes across it. After a short pause, the old man resumes rowing. He lowers his center of gravity and completes one slow, deliberate stroke, then begins a second. The rowboat moves forward with the swell. Each time the boat rises onto a wave crest, the ship’s shadow appears taller. When the boat descends into a trough, the lower part of the pirate ship is briefly hidden by water and fog. As the old man continues forward, the bow, the dark gunports, and the lowest black sails become gradually clearer. High-altitude wind causes the enormous black sails to fill and relax with delayed, heavy movement. The rigging tightens and loosens slightly. It must not swing wildly. The pirate ship remains almost motionless, advancing into the swell at an extremely slow speed. [Shot Three | 10–12.5 Seconds | First Wide Shot: Bow Pressure] Cut directly to a low, water-level wide shot using a 28mm lens, observing the rowboat from a distance on its left side. A defocused dark-blue wave crest occupies the foreground. The old man and rowboat sit in the middle ground. The pirate ship’s towering bow, lower black sails, and fog-covered masts dominate the background. The rowboat occupies only about 2% of the frame width. The old man is even smaller, but his charcoal cloak and faded reddish-brown scarf remain consistent. The pirate ship’s bow rises from the sea like a dark timber cliff. Large hull planks, gunports, and matte iron reinforcement provide believable scale references. The foreground wave briefly hides the lower half of the rowboat. As the crest falls, the boat reappears in the same spatial relationship to the ship. The vessel’s position, size, and structure remain stable. The upper black sails and masts are partially swallowed by blue-gray fog. This shot emphasizes the vertical height of the bow and the oppressive distance between the small boat and the ship. [Shot Four | 12.5–15 Seconds | Second Wide Shot: Complete Scale] Cut to a distant, slightly elevated extreme-wide shot using a 50mm long lens to compress spatial depth. The camera remains stable without a fast aerial dive, orbit, or rotation. The enormous three-masted pirate ship appears from a front-side angle and dominates the center-right of the frame. Most of the full hull, long bowsprit, high sterncastle, three main masts, and layered black sails enter the frame. The lower hull remains relatively clear, while the upper masts and sail corners fade in and out of the dense fog. The black pirate flag moves slowly at the top of the highest mast. The old man and his boat remain ahead of the ship and slightly to the left, reduced to a tiny dark mark on the cold-blue sea. The rowboat continues rising and falling with the swell as the old man rows slowly. The pirate ship remains heavy, silent, and almost immovable. Maintain a safe distance between both vessels. No collision occurs. Large areas of cold-blue water and empty fog surround the subjects. There is no land, no other vessel, and no modern structure on the horizon. Hold the final composition for approximately half a second, keeping the old man, the small boat, and the enormous black sails visible together. [Editing Logic] The sequence contains four shots and three direct cuts. The first cut moves from the old man raising his head in side profile to the enormous ship shadow seen from behind him. The second cut moves from the old man continuing to row into the low water-level wide shot, revealing the height of the bow. The third cut moves to a farther, slightly elevated extreme-wide shot, revealing the total length of the pirate ship and the surrounding ocean. The two wide shots have different purposes: The first wide shot establishes the vertical pressure of the bow. The second wide shot reveals the full vessel and the final scale relationship between the ship, the ocean, and the rowboat. Do not insert close-ups of skulls, cannons, eyes, rigging hardware, or pirate crew. [Cinematography and Image Texture] A 35mm theatrical release-print look with soft gate edges, medium-low microcontrast, fine and slightly uneven natural grain, layered deep blues and charcoal blacks, and gentle highlight rolloff. Fog and slight condensation along the edge of the lens are the only dominant image imperfections. Do not simultaneously add rain, dust, smoke, sparks, or strong lens flares. The camera retains small amounts of ocean movement and inertia while keeping the horizon generally stable. Close shots use restrained depth of field. Distant contrast decreases naturally through atmospheric perspective. The pirate ship must not appear unnaturally sharp inside the fog, but the entire image must not be covered by a uniform blur filter. [Lighting] The only primary light source is cold dawn light diffused through cloud cover from the upper left. A faint silver-blue edge light touches the left side of the old man’s face, wet hair, wooden oars, and gunwale. The side of the pirate ship facing the light reveals low-contrast cold-gray structure. The backlit side falls toward deep blue-black while preserving readable timber layers. Do not use moonbeams, torchlight, ghostly green illumination, magical glow, lightning, or warm sunset light. [Sound] No dialogue, narration, or musical score. The soundscape consists of wooden oars entering the sea, low water displacement, friction from the oarlocks, creaking rowboat planks, the old man’s controlled but strained breathing, wind softened by fog, one distant bronze ship-bell note, the deep groan of the enormous wooden hull under wave pressure, rigging tightening, and black sails filling slowly with wind. The bell and timber groan are heard before the pirate ship appears in Shot One. Low wooden resonance expands when the ship is revealed in Shot Two. In the final two wide shots, ocean waves, wind, and large areas of silence again dominate the soundscape. [Negative Constraints] Avoid changes to the old man’s face, age, skin tone, beard length, hairstyle, cloak color, scarf, or clothing between shots. Do not make him suddenly younger. Avoid modern clothing, armor, aristocratic costume, stereotypical pirate clothing, modern life jackets, plastic, zippers, modern shoes, wristwatches, firearms, or electronic equipment. Avoid incorrect anatomy, distorted body proportions, extra arms, extra fingers, changing numbers of oars, hands passing through oar handles, rigid rowing, mechanical looping movements, sliding feet, exaggerated screaming, an open-mouthed expression of terror, theatrical fear, heroic posing, empty eyes, or frozen eyes. Avoid changes to the rowboat’s dimensions, color, design, or material. Do not transform the boat into a modern lifeboat, fishing vessel, sailboat, or plastic boat. Avoid weightless floating, sudden sideways movement, violent bouncing, capsizing, flooding, breaking apart, or colliding with the pirate ship. Avoid changes to the pirate ship’s design, length, hull shape, bow, stern, or color. Maintain exactly three main masts. Do not allow masts to appear or disappear. Avoid changing the black sails’ color, melting canvas, incoherent rigging, swapped bow and stern, moving gunports, shipping containers, steam engines, modern motors, steel superstructures, or electric lights. Avoid a cartoon pirate ship, theme-park attraction, miniature model, plastic wood grain, glowing ghost ship, transparent hull, skeleton crew, ghosts, zombies, sea monsters, tentacles, flying ships, magic, green ghost flames, burning sails, cannon battles, explosions, or lightning. Avoid fog disappearing suddenly between cuts or changing color. Do not make the fog behave like fast-moving smoke. The pirate ship must not deform as fog passes over it. Avoid tsunami waves, vertical walls of water, gelatinous water, fabric-like water, smoke-like water, changing wave direction, sudden daylight changes, warm sunset colors, or night. Avoid fast editing, additional shots, location changes, time jumps, flash transitions, dissolves, morphing transitions, camera teleportation, high-speed drone dives, rotating around the ship, sudden zooms, dolly zooms, slow motion, or speed ramping. Avoid video-game cutscenes, fantasy concept art, pirate-themed advertising, uniform sharpness across the entire frame, excessive sharpening, excessive skin smoothing, oversaturation, commercial teal-and-orange grading, neon, lasers, decorative light beams, excessive lens flare, text, subtitles, brand logos, and modern watermarks. Allow the simple skull-and-crossbones pirate flag at the top of the highest mast, but do not place any readable text on the flag.
A detailed build prompt for AI coding models that produces a real-time, endlessly looping cyberpunk pixel-art city diorama for a 10-hour lo-fi music video: a rain-soaked megacity at 2:37 AM with warm windows, flickering neon, fog, steam and tiny moving details, rendered at 640×360 and upscaled exactly 3× with nearest-neighbor for razor-sharp pixels, all delivered as one standalone HTML file with a clean full-screen mode for recording.
# Build an Immersive, Animated Cyberpunk Pixel-Art Diorama Create a breathtaking, richly detailed, fully animated cyberpunk pixel-art diorama designed to serve as the endlessly looping visual backdrop for a 10-hour lo-fi music video on YouTube. I want something genuinely mesmerizing. A tiny, self-contained world that feels alive. The kind of scene viewers can stare at for hours and continually discover little details they hadn't noticed before. ## Creative Direction Imagine a forgotten corner of a sprawling futuristic megacity, sometime around 2089. It's 2:37 AM. Rain has been falling for days. The streets are soaked, the buildings are ancient and overcrowded, and the city glows with a thousand little signs of life. The atmosphere should feel simultaneously melancholic, cozy, mysterious, and strangely comforting. This is a world where technology has taken over everything, but human warmth still survives in little pockets. Think cinematic cyberpunk meets the intimate charm of meticulously crafted pixel-art environments. Don't settle for a generic neon skyline. Create a place with personality, history, visual storytelling, and a strong sense of depth. ## Visual Composition Design a beautiful, layered environment with a compelling focal point and plenty of environmental storytelling. Consider elements such as: - A tiny apartment or rooftop hideaway overlooking a vast, rain-drenched futuristic city. - Glowing windows revealing glimpses of other people's lives. - Holographic advertisements, animated signage, and distant traffic. - Clusters of air-conditioning units, ventilation ducts, exposed wiring, satellite dishes, and improvised technology. - Warmly illuminated vending machines, computer terminals, and mysterious electronic equipment. - Rainwater collecting in puddles and cascading from rooftops. - Distant elevated trains or flying vehicles silently moving through the mist. - Tiny human silhouettes occasionally visible behind windows. - Overgrown plants reclaiming corners of this technological world. These are creative possibilities, not a mandatory checklist. Make deliberate artistic choices. Prioritize a coherent, memorable composition over cramming everything into the frame. The scene should feel handcrafted, with exquisite attention to small details. ## Color and Lighting Build the entire visual identity around dramatic warm/cold contrast. The outside world is dominated by deep midnight blues, desaturated teals, indigo shadows, and cold cyan rain. Against this darkness, create beautiful pockets of warmth: - Rich amber light spilling from apartment windows. - Soft orange and golden illumination from lamps and electronics. - Occasional saturated magenta, electric violet, and crimson neon. - Warm reflections shimmering across cold, wet surfaces. Use lighting to tell a story. Every illuminated window should feel like a little sanctuary against the enormous, indifferent city. The overall image should be dark and atmospheric without becoming muddy or losing detail. ## Animation: Make the World Feel Alive This is absolutely critical. I don't want a static illustration with a generic rain effect. I want a living pixel-art environment with dozens of subtle, independently animated details. Build a sophisticated system of layered ambient animations: ### Weather and atmosphere - Beautiful multi-depth rainfall with varying speeds and intensities. - Occasional heavier gusts that push rain diagonally. - Thin layers of drifting fog and atmospheric haze. - Smoke rising from vents and chimneys, curling into the night. - Water dripping from ledges and splashing into puddles. - Ripples spreading across reflective surfaces. ### Lighting and electronics - Neon signs flickering irregularly, with convincing electrical behavior. - Screens cycling through mysterious data and advertisements. - Small indicator lights blinking asynchronously. - Warm interior lights gently fluctuating. - Occasional sparks from damaged electrical equipment. - Animated reflections responding to nearby light sources. ### Environmental life - Slowly rotating ventilation fans. - Distant vehicles passing behind buildings. - Tiny silhouettes moving behind illuminated windows. - Steam escaping from pipes. - Hanging cables or signs subtly swaying in the wind. - Occasional small, unexpected details that reward prolonged viewing. Every effect should have its own rhythm. Avoid synchronized, repetitive animation that makes the world feel artificial. ## Seamless Infinite Looping This scene must run beautifully for 10 hours or longer without a visible reset, jump, or discontinuity. Design every animation to be mathematically seamless or continuously procedural. Use independent animation cycles, staggered timing, seeded randomness, and layered motion to create the impression of endless organic variation. No sudden resets. No obvious looping rain textures. No visible animation seams. The scene should remain interesting over time without becoming distracting. This is lo-fi music, so the animation should be hypnotic and relaxing rather than chaotic. Reserve dramatic events for rare, carefully controlled moments. ## Pixel-Art Rendering: Non-Negotiable The visual style must be authentic, razor-sharp pixel art, not a high-resolution illustration with a pixelation filter. Technical requirements: - Internal rendering resolution: 640 × 360 - Final presentation resolution: 1920 × 1080 - Exact integer upscale: 3× - Scaling: nearest-neighbor only - Every source pixel must become a perfectly sharp 3×3 block. - Absolutely no bilinear filtering, blurry edges, antialiasing, or subpixel rendering. - No smooth vector graphics mixed into the pixel-art scene. - All effects, including particles, rain, fog, reflections, and lighting, must respect the pixel grid. - Maintain consistent pixel density across every visual layer. The result should look spectacular on a full-screen 1080p YouTube video, with beautiful, deliberately placed hard pixels. ## Technical Implementation Build this as a complete, self-contained, real-time animated experience. Use whatever rendering architecture delivers the best combination of visual quality, performance, and pixel-perfect consistency. HTML5 Canvas, WebGL, or a hybrid approach are all acceptable. Prioritize: - Smooth, efficient rendering at 1080p. - Deterministic, seamless animation. - Rich visual depth through layered parallax and atmospheric perspective. - Procedural particle systems for weather and environmental effects. - Carefully controlled lighting, blending, and reflections. - Stable performance over extremely long runtimes. - A fixed camera with no unintended movement or jitter. - Correct resizing while preserving the 16:9 composition and pixel-perfect scaling. If useful, implement the world as multiple independently animated layers, all composited at the internal resolution before the final upscale. Include a way to run the scene full-screen and hide any interface elements so it can be captured cleanly for YouTube. ## Artistic Quality Bar This should feel like an exceptional piece of animated pixel art, not a programming demonstration. Composition, atmosphere, color harmony, and environmental storytelling matter just as much as technical sophistication. Avoid generic procedural rectangles masquerading as buildings. Give the architecture intentional shapes, recognizable structures, believable construction details, and visual variety. Use foreground silhouettes, middle-ground architecture, and distant city layers to create a strong sense of scale. Every element should contribute to a cohesive world. The ultimate goal: A mesmerizing cyberpunk night scene that makes someone wish they could climb through their monitor, sit beside a warm window with a cup of coffee, and listen to the rain falling on a futuristic city that never sleeps. Make it beautiful. Make it atmospheric. Make it technically excellent. And above all, make it feel alive. Don't just describe how you would build it. Actually implement the complete working experience. Deliver the finished piece as a single standalone HTML file (all code inline, no external assets or build step) that runs by opening it in a browser.
Opinionated conventions for designing reliable, typed and easy-to-use API client SDKs.
# API Client SDK taste of a developer-experience focused SDK maintainer - Generate the low-level client from an OpenAPI spec, and hand-write only a thin, idiomatic layer on top. Confidence: 0.85 - Treat the API spec as the source of truth and fail CI when the generated code is out of date. Confidence: 0.85 - Make the first call possible in three lines: install, create a client with an API key, call one method. Confidence: 0.90 - Read the API key from a well-known environment variable by default, and never log it or include it in error messages. Confidence: 0.95 - Expose fully typed request and response models, with TypeScript types or Python type hints shipped in the package. Confidence: 0.90 - Return typed errors with the HTTP status, error code, message and request ID, instead of a generic exception. Confidence: 0.90 - Retry idempotent requests on timeouts, 429 and 5xx responses with exponential backoff and jitter, and respect Retry-After headers. Confidence: 0.90 - Send an idempotency key automatically on retried POST requests when the API supports it. Confidence: 0.80 - Set sensible default timeouts and let callers override timeout and retry settings per client and per request. Confidence: 0.85 - Provide auto-paginating iterators so users can loop over all results without handling cursors themselves. Confidence: 0.85 - Support streaming responses with native async iterators or generators instead of callbacks. Confidence: 0.80 - Offer both sync and async clients in languages where both are common, sharing the same models. Confidence: 0.75 - Keep runtime dependencies minimal and avoid pinning users to a specific HTTP library version. Confidence: 0.85 - Send a User-Agent header with the SDK name, version and runtime so API owners can see adoption and debug issues. Confidence: 0.80 - Follow semantic versioning strictly and document breaking changes with migration examples in the changelog. Confidence: 0.90 - Never remove a method in a minor release; deprecate it with a warning first. Confidence: 0.85 - Test against recorded HTTP fixtures for speed and run a smaller suite against a real sandbox environment nightly. Confidence: 0.80 - Run the test suite on every supported runtime version in CI, such as each active Node.js LTS or Python release. Confidence: 0.85 - Put runnable code examples in the README and docs, and test those examples in CI so they never go stale. Confidence: 0.80 - Expose hooks or middleware for logging, tracing and custom headers instead of adding one-off options for each need. Confidence: 0.75 - Allow a custom base URL and HTTP client so users can point the SDK at proxies, mocks or regional endpoints. Confidence: 0.80 - Publish packages from CI with provenance and signed releases, never from a maintainer's laptop. Confidence: 0.75
Opinionated conventions for structuring, testing and shipping dbt analytics projects.
# dbt Analytics Project taste of a careful analytics engineer - Layer models into staging, intermediate and marts folders, with one staging model per source table. Confidence: 0.90 - Keep staging models to renaming, casting and light cleanup only; put joins and business logic in intermediate and mart models. Confidence: 0.90 - Reference raw data only through source() in staging models and use ref() everywhere else. Confidence: 0.95 - Name models with clear prefixes such as stg_, int_, fct_ and dim_ so their role is obvious in lineage graphs. Confidence: 0.85 - Declare a primary key for every model and test it with unique and not_null tests. Confidence: 0.95 - Add relationships and accepted_values tests on important foreign keys and status columns. Confidence: 0.85 - Configure source freshness checks with warn and error thresholds and run them before the main build. Confidence: 0.85 - Write SQL in lowercase with CTEs at the top, one CTE per logical step, and a final select from the last CTE. Confidence: 0.80 - Lint and format SQL with SQLFluff in CI so style debates never reach code review. Confidence: 0.80 - Use incremental models only when full refreshes are too slow, and always define unique_key and a late-arriving data window. Confidence: 0.85 - Materialize staging models as views and marts as tables or incremental models, unless profiling shows a better choice. Confidence: 0.75 - Keep business metrics defined once in the semantic layer or a single mart, not recalculated in every dashboard. Confidence: 0.85 - Document every mart model and its columns in YAML, including the grain of the table in plain words. Confidence: 0.90 - Run slim CI with state:modified+ and deferral against production artifacts so pull requests build only what changed. Confidence: 0.85 - Build each pull request into its own temporary schema and drop it after merge. Confidence: 0.80 - Pin dbt and package versions in packages.yml and the project requirements, and upgrade them deliberately. Confidence: 0.85 - Keep macros few and well tested; prefer readable SQL over clever Jinja. Confidence: 0.80 - Add unit tests for tricky transformation logic using dbt unit tests with small, explicit input fixtures. Confidence: 0.75 - Use snapshots for slowly changing dimensions only where history is genuinely needed by the business. Confidence: 0.75 - Tag models by owner and domain, and route test failures to the owning team's channel. Confidence: 0.70 - Avoid select * in marts; list columns explicitly so schema changes upstream do not silently leak through. Confidence: 0.85 - Set model contracts on public marts that other teams or tools depend on. Confidence: 0.70
Opinionated conventions for building accessible, themeable and well-versioned design systems and component libraries.
# Design System taste of a pragmatic design systems engineer - Define design tokens for color, spacing, typography, radius and motion in one source file, and generate CSS variables and platform outputs from it. Confidence: 0.90 - Name tokens by purpose, such as color-text-muted or space-inset-md, not by raw value like gray-500 or 16px. Confidence: 0.85 - Support light and dark themes by swapping semantic token values, never by overriding individual component styles. Confidence: 0.90 - Build components on accessible headless primitives such as Radix UI or React Aria instead of re-implementing focus and keyboard logic. Confidence: 0.85 - Meet WCAG 2.2 AA contrast for every token pairing and check it automatically when tokens change. Confidence: 0.90 - Keep the component API small: a few well-named variants and sizes beat dozens of boolean props. Confidence: 0.85 - Prefer composition with slots or children over configuration objects for complex components like dialogs and menus. Confidence: 0.80 - Forward refs and spread remaining props onto the root element so components work with forms, tooltips and tests. Confidence: 0.85 - Never hard-code colors, spacing or font sizes inside components; lint for raw values with Stylelint or an ESLint rule. Confidence: 0.85 - Document every component in Storybook with usage guidance, do and don't examples, and live controls. Confidence: 0.85 - Run visual regression tests with Chromatic or Playwright screenshots on every pull request. Confidence: 0.80 - Run automated accessibility checks with axe in component tests, and still test keyboard and screen reader flows by hand before release. Confidence: 0.85 - Publish the library as a versioned package with semantic versioning and a changelog generated by Changesets. Confidence: 0.90 - Treat a renamed prop or removed variant as a breaking change, and ship codemods for large migrations. Confidence: 0.75 - Deprecate before removing: mark the old API, log a development-only warning, and remove it in the next major version. Confidence: 0.85 - Ship ESM with tree-shakeable per-component entry points and mark CSS files as side effects correctly. Confidence: 0.80 - Respect prefers-reduced-motion and keep motion tokens short and purposeful. Confidence: 0.80 - Keep icons as a separate package of optimized SVG components with consistent size and stroke tokens. Confidence: 0.75 - Use logical CSS properties like margin-inline-start so components work in right-to-left layouts. Confidence: 0.75 - Keep Figma components and code components in sync by sharing token names and reviewing drift every release. Confidence: 0.70 - Measure adoption by scanning product repositories for library imports and one-off overrides. Confidence: 0.65 - Accept contributions through a short proposal template that covers the use case, API and accessibility before any code. Confidence: 0.70
Plans car maintenance from your service schedule and service history - estimates how far you drive from odometer readings, works out what is overdue, due soon or next due by distance or time (whichever comes first), bundles items into fewer workshop visits and forecasts costs with a monthly set-aside. Flags overdue safety and legal items, missing records and odometer typos. Includes an offline stdlib Python planner.
---
name: car-maintenance-service-schedule-planner
description: Plans car maintenance from your service schedule and service history - estimates how far you drive from odometer readings, works out what is overdue, due soon or next due by distance or time (whichever comes first), bundles items into fewer workshop visits and forecasts costs with a monthly set-aside. Flags overdue safety and legal items, missing records and odometer typos. Includes an offline stdlib Python planner.
---
# Car Maintenance Service Schedule Planner
You help car owners keep up with maintenance without overpaying. People lose track because every item runs on its own clock: oil every 15,000 km or 12 months, brake fluid every 2 years regardless of distance, the roadworthiness test every year, a timing belt at 120,000 km or 8 years. Something is always about to be due, receipts are scattered and the expensive items arrive as a surprise. You turn the owner's schedule and their service history into a clear list of what is due, a short visit plan that groups items into as few workshop trips as possible, and a cost forecast they can budget for.
You plan from the intervals the owner gives you (from the owner's manual, the service booklet or the dealer). You do not diagnose faults or decide whether a part is worn; tell the user to follow the workshop's findings and the manufacturer's schedule, and to get warning lights, noises, brake or steering problems checked straight away.
## Files in this skill
- `scripts/car_service_plan.py` - reads a car JSON (vehicle, current odometer, schedule items with km and/or month intervals, severe-service intervals, first-test delay, cost, safety/legal flags, name aliases) and a service log CSV (date, odometer, items separated by ";", cost, shop); estimates yearly distance from the readings, finds the last time each item was done, computes the next due date by time and by projected distance, statuses OVERDUE / DUE SOON / OK, bundled workshop visits over the horizon and costs per quarter; reports findings CS01-CS09 (Python 3.8+ standard library only)
- `references/intervals-and-severe-service.md` - how to read the manufacturer's schedule, time vs distance, severe-service conditions, typical item notes and what never to postpone
- `references/checks-and-codes.md` - every finding code, its severity and what to do, plus the input fields
- `templates/service-plan.md` - the one-page plan and the checklist to take to the workshop
- `examples/car.json` - a 2019 petrol estate car at 83,400 km with 11 schedule items (illustrative intervals and prices - replace with your manual's)
- `examples/service-log.csv` - seven years of service history with combined visits, an alias ("Oil change"), an item not in the schedule and an odometer typo
- `examples/service-plan.md` - the real planner output on the example
## Workflow
### 1. Collect the schedule and the history
Ask for the make, model, engine and first registration date, today's odometer reading, and the maintenance schedule from the owner's manual or service booklet (item, distance interval, time interval, severe-service intervals if they drive mostly short trips, tow, or drive in dust or extreme heat or cold). Ask for the service history: invoices, stamps in the service book or the dealer's printout, with date, odometer and what was done. Add rough prices from their workshop's last invoices. Write them into a car JSON like `examples/car.json` and a log like `examples/service-log.csv`.
### 2. Run the planner
```
python3 scripts/car_service_plan.py examples/car.json examples/service-log.csv
python3 scripts/car_service_plan.py car.json log.csv --odometer 84100 --today 2026-11-20
python3 scripts/car_service_plan.py car.json log.csv --severe --horizon 36
python3 scripts/car_service_plan.py car.json log.csv --json
python3 scripts/car_service_plan.py --self-test
```
Exit 0 = no overdue safety or legal item, 1 = a safety/legal item is overdue (or the log has a high finding), 2 = bad input. Without a shell, for each item take the last date and odometer, add both intervals, convert the distance to a date with the yearly distance, and take the earlier one.
### 3. Read the result
- High first: CS01 high (overdue brakes, brake fluid, tyres, legal test) - drive carefully and book now; CS04 high (dates in the future, readings above today's) - fix the log.
- Medium: other overdue items, items due soon, items with no record (CS03 - counted from new, so check the service book before paying for them) and odometer readings that go backwards.
- Low/info: big-ticket items inside the plan (CS08), items done well before their interval (CS05), log entries not in the schedule, severe intervals in use.
- If the yearly distance looks wrong, check the readings; use `--odometer` with today's reading.
### 4. Plan the visits
Use the visit table: book visit 1 for everything overdue plus what is due within the bundle window, and ask the workshop to quote the whole list in one go. Move a cheap item a few weeks forward if it saves a trip, but never push a safety or legal item later. Fill `templates/service-plan.md` with the visits, the checklist for the workshop and the monthly set-aside.
### 5. Deliver
Return the overdue and due-soon list in plain words, the visit plan with dates and estimated costs, the 12- and 24-month cost forecast and the monthly amount to put aside, plus questions to check (records for items with no history, the exact interval for their engine, warranty conditions that require dealer servicing).
## Example result
For the 2019 Octavia at 83,400 km (about 11,000 km a year) four items are overdue: brake fluid (126 days, safety), the inspection service and air filter (by time) and the tyre rotation (3,300 km), and the roadworthiness test is due on 20 Nov 2026 - one visit now covers all five for about 410 EUR. There is no record of the timing belt, so it is counted from new and due by age in November 2027 (about 680 EUR), and new tyres are projected for June 2027 (about 560 EUR). Maintenance over the next 24 months comes to about 2,445 EUR, so set aside about 102 EUR a month. The 4,790 km reading in Sept 2023 is a typo and is ignored.
## Rules
- Use the manufacturer's intervals for the exact engine; the example values are illustrative only.
- Whichever comes first: an item is due at the earlier of its distance and its time limit, even for a car that is hardly driven.
- Never suggest postponing brakes, tyres, steering, warning lights or a legal test to save money or a trip.
- An item with no record is unknown, not done: say so and suggest checking the service book or asking the workshop.
- Costs are estimates; tell the user to get a quote, and mention warranty conditions before suggesting an independent garage.
FILE:references/intervals-and-severe-service.md
# Intervals and severe service
## Where the intervals come from
- The owner's manual or service booklet for your exact model, engine and model year. The same model can have different schedules for petrol, diesel and hybrid engines, and for "fixed" vs "flexible/long-life" service regimes.
- Many cars show the next oil service on the dashboard (service indicator). If it disagrees with the booklet, ask the dealer which regime the car is set to.
- Keep warranty conditions in mind: some manufacturers require servicing to their schedule with genuine-spec parts; independent garages are usually fine if they follow the schedule and keep invoices.
## Whichever comes first
Every item with both a distance and a time interval is due at the EARLIER of the two. Low-mileage cars hit the time limit (oil ages, brake fluid absorbs water, rubber belts and tyres age); high-mileage cars hit the distance limit. The planner projects the distance limit to a date with your yearly distance.
## Severe service
Most manuals list shorter intervals when the car is used mostly for:
- short trips (under about 10 km, engine rarely fully warm), especially in cold weather;
- stop-and-go city traffic, taxi or delivery use, long idling;
- towing a trailer or caravan, roof loads, mountain driving;
- dusty or sandy roads, very hot or very cold climates.
If any of these describe most of your driving, use `--severe` (or `"severe": true`) so items with `severe_km` / `severe_months` use the shorter interval.
## Item notes (typical, check your manual)
| Item | Typical basis | Notes |
|---|---|---|
| Engine oil and filter | distance and time | the most important routine item; use the oil spec in the manual |
| Inspection service | distance and time | the bigger service; usually includes many checks |
| Brake fluid | time only (often 2 years) | absorbs water; never postpone |
| Brake inspection / pads / discs | distance and time | replace on wear, not on a fixed interval |
| Coolant | time or "lifetime" | check level and freeze protection each winter |
| Air filter, cabin filter | distance and time | shorter in dusty or polluted areas |
| Spark plugs (petrol) | distance and time | long intervals on modern engines |
| Timing belt (if fitted) | distance and time | failure can destroy the engine; often changed with the water pump; chains usually have no interval |
| Tyres | wear and age | check tread depth monthly; many makers suggest replacing at 6-10 years regardless of tread |
| Tyre rotation | distance | if your manual recommends it (not for different-size front/rear tyres) |
| Roadworthiness test | time (legal) | first test after a few years (e.g. 3 or 4), then every 1-2 years depending on the country |
| 12 V battery | condition | test before winter once it is 4-5 years old |
## Legal tests
Rules differ by country (MOT in the UK, TUV/HU in Germany, CT in France, TUVTURK in Turkey, state inspections in the US). Enter your country's first-test delay as `first_months` and the repeat interval as `months`, and mark the item `"legal": true`. Many schemes let you test a little early without losing the date; check yours before bundling it into an earlier visit.
## What never to postpone
Brakes and brake fluid, tyres (tread, damage, age), steering and suspension faults, warning lights (oil pressure, brakes, engine temperature), and the legal test. The planner marks these with `safety` / `legal` and treats them as high when overdue.
## Getting the inputs
- Odometer and date of every visit: invoices, the service book stamps, the dealer's digital service record, the legal test certificates (they record the odometer).
- Prices: your last invoices or a quote; add 10-20% for parts that are often replaced at the same time (wipers, bulbs, brake pads).
FILE:references/checks-and-codes.md
# Checks and finding codes
| Code | Severity | Check | What to do |
|---|---|---|---|
| CS01 | high: safety or legal item overdue; medium: other item overdue | due date or due distance passed | book it; for safety items drive carefully until done |
| CS02 | medium | due within `due_soon.days` or `due_soon.km` | book it with the next visit |
| CS03 | medium | no record of the item in the log: counted from first registration | check the service book / receipts / dealer record before paying for it |
| CS04 | high: date in the future, reading above the current odometer; medium: reading lower than an earlier one, unreadable odometer | log consistency | fix the typo; the reading is ignored for distance and due calculations |
| CS05 | info | last done at under 60% of both intervals | fine if intended (severe use, warranty); otherwise money spent early |
| CS06 | info | severe-service intervals in use | check the conditions still apply |
| CS07 | info | log item not in your schedule (repairs, extras) | kept for history; add aliases if it is a schedule item under another name |
| CS08 | low | item costing 400 or more falls inside the plan horizon | budget for it; get a quote early |
| CS09 | medium | not enough odometer readings (a reading at least 60 days back): default yearly distance used | add readings (test certificates, fuel app, photos of the dashboard) |
## Verdict
- **Overdue including safety/legal**: book now.
- **Overdue**: book soon.
- **Due soon** / **up to date**.
## How the numbers are worked out
- Yearly distance: from the latest log reading at least a year before today to today's reading (the earliest reading if none is that old).
- Next due: last done date + months, and last done odometer + km, converted to a date with your daily distance; the earlier wins. An item with no record counts from first registration at 0 km (`first_months` replaces `months` for the first time, e.g. a first legal test after 36 months).
- Horizon: each item repeats from the date it is assumed done (its due date, or today if overdue).
- Visits: items due within `bundle.days` and `bundle.km` of the first item of a visit join that visit (pulled forward, never pushed later).
- Costs: each occurrence's `cost` added to its quarter; monthly set-aside = horizon total / horizon months.
## Car JSON keys
`vehicle{name, first_registered, unit}, odometer{reading, date}, driving{default_per_year, severe, severe_reasons}, currency, horizon_months, due_soon{km, days}, bundle{km, days}, schedule[{item, km, months, severe_km, severe_months, first_months, cost, safety, legal, aliases[]}]`.
## Service log CSV
`date, odometer, item, cost, shop, notes` - several items in one visit separated by `;`. Names are matched to schedule items and aliases case-insensitively. The cost column is for your records; the forecast uses the schedule prices.
FILE:templates/service-plan.md
# Car service plan: <car, registration>
**Date:** <YYYY-MM-DD> **Odometer:** <83,400 km> **Yearly distance:** <about 11,000 km> **Schedule:** <owner's manual, normal / severe service>
## Do now
| Item | Why | Est. cost |
|---|---|---|
| <Brake fluid> | <overdue by 4 months (2-year interval) - safety> | <85> |
| <Roadworthiness test> | <due 2026-11-20 - legal> | <60> |
## Next visits
| When (book by) | At about | Items | Est. cost |
|---|---|---|---|
| <2027-02-14> | <86,500 km> | <oil and filter, brake inspection> | <180> |
## Budget
- Next 12 months: <1,220> <EUR>
- Next 24 months: <2,445> <EUR> -> set aside <102> per month
- Big items ahead: <timing belt kit and water pump, Nov 2027, about 680>
## To check
- [ ] <no record of the timing belt - check the service book / ask the dealer>
- [ ] <exact brake fluid interval for this engine>
- [ ] <warranty: does it require dealer servicing?>
## Checklist for the workshop
- Car: <make, model, engine, VIN if asked>, odometer <...>
- Please do: <list from "Do now">
- Please check and quote before replacing: <brake pads/discs, tyres, wipers, battery>
- Please stamp the service book / send a digital record and note the odometer on the invoice.
*Intervals from <source>; costs are estimates. Get anything unusual (warning lights, noises, pulling, vibration) checked straight away.*
FILE:examples/car.json
{
"vehicle": {"name": "2019 Skoda Octavia Estate 1.5 TSI (petrol)", "first_registered": "2019-11-14", "unit": "km"},
"odometer": {"reading": 83400, "date": "2026-11-04"},
"driving": {"default_per_year": 12000, "severe": false, "severe_reasons": ["mostly short trips in winter", "occasional caravan towing"]},
"currency": "EUR",
"horizon_months": 24,
"due_soon": {"km": 1500, "days": 45},
"bundle": {"km": 2500, "days": 60},
"schedule": [
{"item": "Engine oil and filter", "km": 15000, "months": 12, "severe_km": 7500, "severe_months": 6, "cost": 140, "aliases": ["oil service", "oil change"]},
{"item": "Inspection service", "km": 30000, "months": 24, "severe_km": 20000, "cost": 190, "aliases": ["inspection"]},
{"item": "Brake inspection", "km": 20000, "months": 12, "cost": 40, "safety": true},
{"item": "Brake fluid", "months": 24, "cost": 85, "safety": true},
{"item": "Air filter", "km": 60000, "months": 48, "severe_km": 30000, "cost": 45},
{"item": "Cabin filter", "km": 30000, "months": 24, "cost": 40, "aliases": ["pollen filter"]},
{"item": "Spark plugs", "km": 60000, "months": 72, "cost": 160},
{"item": "Tyre rotation", "km": 10000, "cost": 30},
{"item": "Tyres (set of 4)", "km": 45000, "months": 72, "cost": 560, "safety": true},
{"item": "Timing belt kit and water pump", "km": 120000, "months": 96, "cost": 680},
{"item": "Roadworthiness test", "months": 12, "first_months": 36, "cost": 60, "legal": true, "aliases": ["mot", "periodic inspection"]}
]
}
FILE:examples/service-log.csv
date,odometer,item,cost,shop,notes
2020-11-20,11500,Oil service; Brake inspection,165,Main dealer,first service
2021-11-05,22800,Oil service; Brake inspection; Cabin filter,210,Main dealer,
2022-05-28,33900,Oil service; Inspection service; Brake fluid; Air filter,395,Main dealer,
2022-11-08,38000,Roadworthiness test,55,Test centre,passed
2023-06-15,45150,Oil service; Brake inspection; Tyre rotation; Tyres (set of 4),735,QuickFit,new tyres 205/55 R16
2023-09-02,4790,Windscreen chip repair,60,GlassFix,
2023-11-13,49000,Roadworthiness test,55,Test centre,passed
2024-07-01,56900,Oil service; Inspection service; Brake fluid; Spark plugs; Brake inspection,520,Main dealer,
2024-11-12,59800,Roadworthiness test,58,Test centre,advisory: rear wiper
2025-08-20,70100,Oil service; Brake inspection; Cabin filter; Tyre rotation,240,Corner Garage,
2025-11-20,73000,Roadworthiness test,60,Test centre,passed
2026-02-14,75300,Oil change; Brake inspection,150,Corner Garage,dash light came on
FILE:examples/service-plan.md
# Service plan: 2019 Skoda Octavia Estate 1.5 TSI (petrol)
Today 2026-11-04, odometer 83,400 km. You drive about 11,008 km/year (2025-08-20 70,100 -> 2026-11-04 83,400). Normal-service intervals, plan horizon 24 months. Source: examples/service-log.csv.
**Verdict: 4 item(s) overdue, including safety/legal items - book a visit now**
Next 12 months: about 1,220.00 EUR; next 24 months: 2,445.00 EUR (set aside about 101.88 EUR per month). Findings: 1 high, 6 medium, 2 low, 3 info.
## Items
| Item | Interval | Last done | Due by date | Due at km | Next (projected) | Trigger | Status | Est. cost |
|---|---|---|---|---|---|---|---|---|
| Inspection service | 30,000 km / 24 months | 2024-07-01 at 56,900 | 2026-07-01 | 86,900 | 2026-11-04 | time | **OVERDUE** | 190.00 |
| Brake fluid [safety] | 24 months | 2024-07-01 at 56,900 | 2026-07-01 | - | 2026-11-04 | time | **OVERDUE** | 85.00 |
| Air filter | 60,000 km / 48 months | 2022-05-28 at 33,900 | 2026-05-28 | 93,900 | 2026-11-04 | time | **OVERDUE** | 45.00 |
| Tyre rotation | 10,000 km | 2025-08-20 at 70,100 | - | 80,100 | 2026-11-04 | distance | **OVERDUE** | 30.00 |
| Roadworthiness test [legal] | 12 months | 2025-11-20 at 73,000 | 2026-11-20 | - | 2026-11-20 | time | **DUE SOON** | 60.00 |
| Engine oil and filter | 15,000 km / 12 months | 2026-02-14 at 75,300 | 2027-02-14 | 90,300 | 2027-02-14 | time | OK | 140.00 |
| Brake inspection [safety] | 20,000 km / 12 months | 2026-02-14 at 75,300 | 2027-02-14 | 95,300 | 2027-02-14 | time | OK | 40.00 |
| Tyres (set of 4) [safety] | 45,000 km / 72 months | 2023-06-15 at 45,150 | 2029-06-15 | 90,150 | 2027-06-15 | distance | OK | 560.00 |
| Cabin filter | 30,000 km / 24 months | 2025-08-20 at 70,100 | 2027-08-20 | 100,100 | 2027-08-20 | time | OK | 40.00 |
| Timing belt kit and water pump | 120,000 km / 96 months | no record (counted from new, 2019-11-14) | 2027-11-14 | 120,000 | 2027-11-14 | time | OK | 680.00 |
| Spark plugs | 60,000 km / 72 months | 2024-07-01 at 56,900 | 2030-07-01 | 116,900 | 2029-11-18 | distance | OK | 160.00 |
## Workshop visits (items within 60 days / 2,500 km are bundled)
| # | Book by | At about | Items | Est. cost |
|---|---|---|---|---|
| 1 | 2026-11-04 (now) | 83,400 km | Air filter (overdue), Brake fluid (overdue), Inspection service (overdue), Tyre rotation (overdue), Roadworthiness test | 410.00 |
| 2 | 2027-02-14 | 86,476 km | Brake inspection, Engine oil and filter | 180.00 |
| 3 | 2027-06-15 | 90,125 km | Tyres (set of 4) | 560.00 |
| 4 | 2027-08-20 | 92,116 km | Cabin filter, Tyre rotation | 70.00 |
| 5 | 2027-11-14 | 94,710 km | Timing belt kit and water pump, Roadworthiness test | 740.00 |
| 6 | 2028-02-14 | 97,484 km | Brake inspection, Engine oil and filter | 180.00 |
| 7 | 2028-08-27 | 103,365 km | Tyre rotation | 30.00 |
| 8 | 2028-11-04 | 105,446 km | Brake fluid, Inspection service | 275.00 |
## Cost forecast by quarter
| Quarter | Est. cost |
|---|---|
| 2026-Q4 | 410.00 |
| 2027-Q1 | 180.00 |
| 2027-Q2 | 560.00 |
| 2027-Q3 | 40.00 |
| 2027-Q4 | 770.00 |
| 2028-Q1 | 180.00 |
| 2028-Q3 | 30.00 |
| 2028-Q4 | 275.00 |
| **Total 24 months** | **2,445.00** |
## Findings
- **HIGH CS01** Brake fluid is overdue by 126 days (safety item) - book it now
- **MEDIUM CS01** Air filter is overdue by 160 days - book it now
- **MEDIUM CS01** Inspection service is overdue by 126 days - book it now
- **MEDIUM CS01** Tyre rotation is overdue by 3,300 km - book it now
- **MEDIUM CS02** Roadworthiness test is due soon: 2026-11-20
- **MEDIUM CS03** Timing belt kit and water pump: no record in your log - counted from new; check the service book or receipts
- **MEDIUM CS04** row 7: odometer 4,790 on 2023-09-02 is lower than 45,150 on 2023-06-15 - typo or wrong car? reading ignored
- **LOW CS08** Timing belt kit and water pump (about 680.00) falls in the plan on 2027-11-14 - budget for it now
- **LOW CS08** Tyres (set of 4) (about 560.00) falls in the plan on 2027-06-15 - budget for it now
- **INFO CS05** Brake inspection was last done after 5,200 km / 6 months (under 60% of the interval) - fine for severe use, otherwise money spent early
- **INFO CS05** Engine oil and filter was last done after 5,200 km / 6 months (under 60% of the interval) - fine for severe use, otherwise money spent early
- **INFO CS07** row 7: 'Windscreen chip repair' is not in your schedule - kept in the history only
Intervals and prices come from your car file: check them against the owner's manual or service booklet for your exact engine, and follow your workshop if it finds wear earlier. Costs are estimates; tyres and brakes also depend on wear, not only on distance.
FILE:scripts/car_service_plan.py
#!/usr/bin/env python3
"""Car maintenance service schedule planner (stdlib only, Python 3.8+).
Reads your car's maintenance schedule (items with distance and/or time intervals, from the
owner's manual or service booklet) and your service history (CSV), estimates how far you drive
from the odometer readings in the log, and works out for every item when it was last done, when
it is next due (distance or time, whichever comes first), what is overdue or due soon, which
items can share one workshop visit, and what maintenance will cost over the next months.
Usage:
car_service_plan.py CAR.json SERVICE_LOG.csv [--odometer N] [--today YYYY-MM-DD] [--severe]
[--horizon MONTHS] [--json]
car_service_plan.py --self-test
Exit: 0 = nothing overdue on a safety/legal item, 1 = a safety or legal item is overdue (or the
log has a high finding), 2 = bad input.
"""
import argparse, calendar, csv, datetime, io, json, sys
SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
CAR_KEYS = {"vehicle", "odometer", "driving", "currency", "horizon_months", "due_soon", "bundle", "schedule"}
class InputError(Exception):
pass
def pdate(s, what):
try:
return datetime.date.fromisoformat(str(s).strip())
except ValueError:
raise InputError("%s must be YYYY-MM-DD, got %r" % (what, s))
def add_months(d, m):
y, mo = divmod(d.month - 1 + int(m), 12)
y += d.year
mo += 1
return datetime.date(y, mo, min(d.day, calendar.monthrange(y, mo)[1]))
def num(x, what):
try:
return float(str(x).replace(",", "").replace(" ", ""))
except ValueError:
raise InputError("%s is not a number: %r" % (what, x))
def fk(x):
return "{:,.0f}".format(x)
def fm(x):
return "{:,.2f}".format(x)
# ---------------------------------------------------------------- load
def load_car(cfg, odometer=None, today=None, severe=None, horizon=None):
if not isinstance(cfg, dict):
raise InputError("car file must be a JSON object")
bad = set(cfg) - CAR_KEYS
if bad:
raise InputError("unknown key(s) in car file: %s" % ", ".join(sorted(bad)))
v = cfg.get("vehicle") or {}
odo = cfg.get("odometer") or {}
drv = cfg.get("driving") or {}
c = {"name": v.get("name", "Car"), "unit": v.get("unit", "km"),
"first": pdate(v["first_registered"], "vehicle.first_registered") if v.get("first_registered") else None,
"today": pdate(today or odo.get("date") or datetime.date.today().isoformat(), "today"),
"odo": num(odometer if odometer is not None else odo.get("reading"), "odometer reading") if (odometer is not None or odo.get("reading") is not None) else None,
"default_per_year": num(drv.get("default_per_year", 12000), "driving.default_per_year"),
"severe": bool(drv.get("severe", False)) if severe is None else severe,
"severe_reasons": drv.get("severe_reasons", []),
"currency": cfg.get("currency", ""),
"horizon": int(horizon or cfg.get("horizon_months", 24)),
"soon_km": num((cfg.get("due_soon") or {}).get("km", 1500), "due_soon.km"),
"soon_days": int((cfg.get("due_soon") or {}).get("days", 45)),
"bundle_km": num((cfg.get("bundle") or {}).get("km", 2500), "bundle.km"),
"bundle_days": int((cfg.get("bundle") or {}).get("days", 60)),
"items": []}
if c["odo"] is None:
raise InputError("give the current odometer reading (odometer.reading or --odometer)")
for i, it in enumerate(cfg.get("schedule") or [], 1):
name = it.get("item")
if not name:
raise InputError("schedule item %d has no 'item' name" % i)
km = it.get("severe_km") if c["severe"] and it.get("severe_km") else it.get("km")
mo = it.get("severe_months") if c["severe"] and it.get("severe_months") else it.get("months")
if not km and not mo:
raise InputError("schedule item '%s' needs km and/or months" % name)
c["items"].append({"item": name, "km": num(km, name + " km") if km else None, "months": int(mo) if mo else None,
"first_months": int(it["first_months"]) if it.get("first_months") else None,
"cost": num(it.get("cost", 0), name + " cost"), "safety": bool(it.get("safety")), "legal": bool(it.get("legal")),
"severe_used": bool(c["severe"] and (it.get("severe_km") or it.get("severe_months"))),
"aliases": [a.lower().strip() for a in it.get("aliases", [])] + [name.lower().strip()]})
if not c["items"]:
raise InputError("car file needs a non-empty 'schedule'")
return c
def load_log(c, text, F):
rows = list(csv.DictReader(io.StringIO(text)))
if rows and not {"date", "odometer", "item"} <= set(rows[0]):
raise InputError("service log needs columns date, odometer, item (has %s)" % ", ".join(rows[0]))
entries, readings = [], []
for n, r in enumerate(rows, 2):
try:
d = pdate(r.get("date"), "date (row %d)" % n)
except InputError as e:
F.append(("high", "CS04", "row %d: %s - row ignored" % (n, e))); continue
if d > c["today"]:
F.append(("high", "CS04", "row %d: date %s is in the future - row ignored" % (n, d))); continue
odo = None
if (r.get("odometer") or "").strip():
try:
odo = num(r["odometer"], "odometer (row %d)" % n)
except InputError as e:
F.append(("medium", "CS04", "%s - distance ignored for this row" % e))
items = [x.strip() for x in (r.get("item") or "").split(";") if x.strip()]
entries.append({"row": n, "date": d, "odo": odo, "items": items, "cost": r.get("cost", ""), "shop": r.get("shop", "")})
entries.sort(key=lambda e: (e["date"], e["row"]))
last = None
for e in entries:
if e["odo"] is None:
continue
if e["odo"] > c["odo"]:
F.append(("high", "CS04", "row %d: odometer %s on %s is higher than the current reading %s - typo? reading ignored" % (e["row"], fk(e["odo"]), e["date"], fk(c["odo"]))))
e["odo"] = None; continue
if last and e["odo"] < last["odo"]:
F.append(("medium", "CS04", "row %d: odometer %s on %s is lower than %s on %s - typo or wrong car? reading ignored" % (e["row"], fk(e["odo"]), e["date"], fk(last["odo"]), last["date"])))
e["odo"] = None; continue
last = e
readings.append((e["date"], e["odo"]))
readings.append((c["today"], c["odo"]))
return entries, readings
def distance_rate(c, readings, F):
"""km per day from the latest reading at least a year before today (else the earliest)."""
end_d, end_o = readings[-1]
older = [r for r in readings[:-1] if (end_d - r[0]).days >= 365]
start = older[-1] if older else (readings[0] if len(readings) > 1 else None)
if not start or (end_d - start[0]).days < 60 or end_o <= start[1]:
F.append(("medium", "CS09", "not enough odometer readings to estimate your yearly distance - using the default %s %s/year" % (fk(c["default_per_year"]), c["unit"])))
return c["default_per_year"] / 365.0, "default"
rate = (end_o - start[1]) / float((end_d - start[0]).days)
return rate, "%s %s -> %s %s" % (start[0], fk(start[1]), end_d, fk(end_o))
# ---------------------------------------------------------------- plan
def plan(c, log_text):
F = []
entries, readings = load_log(c, log_text, F)
rate, basis = distance_rate(c, readings, F)
known = {a: it for it in c["items"] for a in it["aliases"]}
done = {it["item"]: [] for it in c["items"]}
for e in entries:
for name in e["items"]:
it = known.get(name.lower())
if not it:
F.append(("info", "CS07", "row %d: '%s' is not in your schedule - kept in the history only" % (e["row"], name)))
continue
done[it["item"]].append(e)
def date_at_km(km):
return c["today"] + datetime.timedelta(days=max(0.0, (km - c["odo"]) / rate)) if rate > 0 else None
def km_at_date(d):
return c["odo"] + rate * (d - c["today"]).days
end = add_months(c["today"], c["horizon"])
rows, occ = [], []
for it in c["items"]:
hist = done[it["item"]]
last = hist[-1] if hist else None
if last:
base_d, base_km, base_note = last["date"], last["odo"], "%s at %s" % (last["date"], fk(last["odo"]) if last["odo"] is not None else "?")
else:
if not c["first"]:
raise InputError("no record of '%s' and no vehicle.first_registered to count from" % it["item"])
base_d, base_km, base_note = c["first"], 0.0, "no record (counted from new, %s)" % c["first"]
F.append(("medium", "CS03", "%s: no record in your log - counted from new; check the service book or receipts" % it["item"]))
months = it["months"]
if not last and it["first_months"]:
months = it["first_months"]
due_d = add_months(base_d, months) if months else None
due_km = base_km + it["km"] if (it["km"] and base_km is not None) else None
km_d = date_at_km(due_km) if due_km is not None else None
cands = [(x, w) for x, w in ((due_d, "time"), (km_d, "distance")) if x]
if not cands:
due_date, trigger = c["today"], "unknown"
else:
due_date, trigger = min(cands)
overdue = (due_d is not None and due_d < c["today"]) or (due_km is not None and due_km < c["odo"])
soon = not overdue and ((due_d is not None and (due_d - c["today"]).days <= c["soon_days"]) or
(due_km is not None and due_km - c["odo"] <= c["soon_km"]))
status = "OVERDUE" if overdue else ("DUE SOON" if soon else "OK")
if overdue:
late = []
if due_d is not None and due_d < c["today"]:
late.append("%d days" % (c["today"] - due_d).days)
if due_km is not None and due_km < c["odo"]:
late.append("%s %s" % (fk(c["odo"] - due_km), c["unit"]))
sev = "high" if (it["safety"] or it["legal"]) else "medium"
F.append((sev, "CS01", "%s is overdue by %s%s - book it now" % (it["item"], " and ".join(late), " (safety item)" if it["safety"] else (" (legal requirement)" if it["legal"] else ""))))
elif soon:
F.append(("medium", "CS02", "%s is due soon: %s%s" % (it["item"], due_d if due_d and (due_d - c["today"]).days <= c["soon_days"] else "", (" / at %s %s" % (fk(due_km), c["unit"])) if due_km is not None else "")))
if len(hist) >= 2 and hist[-2]["odo"] is not None and hist[-1]["odo"] is not None:
a, b = hist[-2], hist[-1]
fr = []
if it["km"]:
fr.append((b["odo"] - a["odo"]) / it["km"])
if it["months"]:
fr.append(((b["date"] - a["date"]).days / 30.44) / it["months"])
if fr and max(fr) < 0.6:
F.append(("info", "CS05", "%s was last done after %s %s / %d months (under 60%% of the interval) - fine for severe use, otherwise money spent early" % (it["item"], fk(b["odo"] - a["odo"]), c["unit"], round((b["date"] - a["date"]).days / 30.44))))
rows.append({"item": it["item"], "last": base_note, "interval": " / ".join(x for x in [("%s %s" % (fk(it["km"]), c["unit"])) if it["km"] else "", ("%d months" % months) if months else ""] if x),
"due_date": due_d, "due_km": due_km, "next": max(due_date, c["today"]), "trigger": trigger, "status": status, "cost": it["cost"],
"safety": it["safety"] or it["legal"], "flag": "legal" if it["legal"] else ("safety" if it["safety"] else ""), "severe": it["severe_used"]})
# occurrences in the horizon (each one assumed done on its due date)
d, k, first = max(due_date, c["today"]), due_km, True
guard = 0
while d <= end and guard < 50:
guard += 1
occ.append({"item": it["item"], "date": d, "km": km_at_date(d), "cost": it["cost"], "overdue": first and overdue})
first = False
nd = add_months(d, it["months"]) if it["months"] else None
nk = km_at_date(d) + it["km"] if it["km"] else None
cands = [x for x in (nd, date_at_km(nk) if nk is not None else None) if x]
if not cands:
break
d = min(cands)
if (it["cost"] >= 400) and any(o["item"] == it["item"] for o in occ):
o = next(o for o in occ if o["item"] == it["item"])
F.append(("low", "CS08", "%s (about %s) falls in the plan on %s - budget for it now" % (it["item"], fm(it["cost"]), o["date"])))
if c["severe"]:
F.append(("info", "CS06", "severe-service intervals used%s" % ((" (" + ", ".join(c["severe_reasons"]) + ")") if c["severe_reasons"] else "")))
# visits: group occurrences within the bundle window
occ.sort(key=lambda o: (o["date"], o["item"]))
visits = []
for o in occ:
v = visits[-1] if visits else None
if v and (o["date"] - v["date"]).days <= c["bundle_days"] and o["km"] - v["km"] <= c["bundle_km"]:
v["items"].append(o); continue
visits.append({"date": o["date"], "km": o["km"], "items": [o]})
# costs by quarter
quarters = {}
for o in occ:
q = "%d-Q%d" % (o["date"].year, (o["date"].month - 1) // 3 + 1)
quarters[q] = quarters.get(q, 0.0) + o["cost"]
y1 = sum(o["cost"] for o in occ if o["date"] < add_months(c["today"], 12))
total = sum(o["cost"] for o in occ)
F.sort(key=lambda f: (SEV[f[0]], f[1], f[2]))
return {"car": c, "rate": rate, "basis": basis, "rows": rows, "occ": occ, "visits": visits, "quarters": quarters,
"next12": y1, "total": total, "findings": F, "entries": entries}
# ---------------------------------------------------------------- report
def report(res, title):
c, F = res["car"], res["findings"]
cnt = {s: sum(1 for f in F if f[0] == s) for s in SEV}
cur = (" " + c["currency"]) if c["currency"] else ""
ov = [r for r in res["rows"] if r["status"] == "OVERDUE"]
soon = [r for r in res["rows"] if r["status"] == "DUE SOON"]
L = ["# Service plan: %s" % c["name"], ""]
L.append("Today %s, odometer %s %s. You drive about %s %s/year (%s). %s intervals, plan horizon %d months. Source: %s." % (
c["today"], fk(c["odo"]), c["unit"], fk(res["rate"] * 365), c["unit"], res["basis"], "Severe-service" if c["severe"] else "Normal-service", c["horizon"], title))
L.append("")
if any(r["safety"] for r in ov):
L.append("**Verdict: %d item(s) overdue, including safety/legal items - book a visit now**" % len(ov))
elif ov:
L.append("**Verdict: %d item(s) overdue - book a visit soon**" % len(ov))
elif soon:
L.append("**Verdict: nothing overdue, %d item(s) due soon**" % len(soon))
else:
L.append("**Verdict: up to date**")
L.append("")
L.append("Next 12 months: about %s%s; next %d months: %s%s (set aside about %s%s per month). Findings: %d high, %d medium, %d low, %d info." % (
fm(res["next12"]), cur, c["horizon"], fm(res["total"]), cur, fm(res["total"] / c["horizon"]), cur, cnt["high"], cnt["medium"], cnt["low"], cnt["info"]))
L += ["", "## Items", "", "| Item | Interval | Last done | Due by date | Due at %s | Next (projected) | Trigger | Status | Est. cost |" % c["unit"], "|---|---|---|---|---|---|---|---|---|"]
order = {"OVERDUE": 0, "DUE SOON": 1, "OK": 2}
for r in sorted(res["rows"], key=lambda r: (order[r["status"]], r["next"])):
L.append("| %s%s | %s | %s | %s | %s | %s | %s | %s | %s |" % (
r["item"], (" [%s]" % r["flag"]) if r["flag"] else "", r["interval"] + (" (severe)" if r["severe"] else ""), r["last"],
r["due_date"] or "-", fk(r["due_km"]) if r["due_km"] is not None else "-", r["next"], r["trigger"],
"**%s**" % r["status"] if r["status"] != "OK" else "OK", fm(r["cost"])))
L += ["", "## Workshop visits (items within %d days / %s %s are bundled)" % (c["bundle_days"], fk(c["bundle_km"]), c["unit"]), "",
"| # | Book by | At about | Items | Est. cost |", "|---|---|---|---|---|"]
for i, v in enumerate(res["visits"], 1):
L.append("| %d | %s%s | %s %s | %s | %s |" % (i, v["date"], " (now)" if v["date"] == c["today"] else "", fk(v["km"]), c["unit"],
", ".join(o["item"] + (" (overdue)" if o["overdue"] else "") for o in v["items"]), fm(sum(o["cost"] for o in v["items"]))))
L += ["", "## Cost forecast by quarter", "", "| Quarter | Est. cost |", "|---|---|"]
for q in sorted(res["quarters"]):
L.append("| %s | %s |" % (q, fm(res["quarters"][q])))
L.append("| **Total %d months** | **%s** |" % (c["horizon"], fm(res["total"])))
L += ["", "## Findings", ""]
L += ["- **%s %s** %s" % (s.upper(), code, m) for s, code, m in F] or ["- none"]
L += ["", "Intervals and prices come from your car file: check them against the owner's manual or service booklet for your exact engine, and follow your workshop if it finds wear earlier. Costs are estimates; tyres and brakes also depend on wear, not only on distance."]
return "\n".join(L) + "\n"
def to_json(res):
def j(x):
if isinstance(x, datetime.date):
return x.isoformat()
if isinstance(x, float):
return round(x, 2)
if isinstance(x, dict):
return {k: j(v) for k, v in x.items()}
if isinstance(x, list):
return [j(v) for v in x]
return x
return j({"km_per_year": res["rate"] * 365, "items": res["rows"], "visits": [{"date": v["date"], "km": v["km"], "items": [o["item"] for o in v["items"]],
"cost": sum(o["cost"] for o in v["items"])} for v in res["visits"]], "quarters": res["quarters"], "next12": res["next12"], "total": res["total"],
"findings": [{"severity": s, "code": c, "message": m} for s, c, m in res["findings"]]})
# ---------------------------------------------------------------- self-test
def self_test():
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
def car(**kw):
cfg = {"vehicle": {"name": "T", "first_registered": "2020-01-01"}, "odometer": {"reading": 50000, "date": "2026-01-01"},
"schedule": [{"item": "Oil", "km": 15000, "months": 12, "severe_km": 7500, "severe_months": 6, "cost": 100, "aliases": ["oil change"]}]}
sched = kw.pop("schedule", None)
if sched:
cfg["schedule"] = sched
cfg.update(kw)
return cfg
H = "date,odometer,item,cost,shop\n"
def run(cfg, log, **kw):
return plan(load_car(cfg, **kw), H + log)
def row(res, name):
return next(r for r in res["rows"] if r["item"] == name)
def codes(res):
return [(f[0], f[1]) for f in res["findings"]]
log = "2025-01-01,40000,Oil,90,X\n"
r = run(car(), log)
t("rate from readings: 10000 km/year", abs(r["rate"] * 365 - 10000) < 1)
t("due by time first (12 months)", row(r, "Oil")["due_date"] == datetime.date(2026, 1, 1) and row(r, "Oil")["trigger"] == "time")
t("due today counts as due soon", row(r, "Oil")["status"] == "DUE SOON")
r = run(car(), "2025-06-01,40000,Oil,90,X\n")
t("distance triggers first when driving a lot", row(r, "Oil")["due_km"] == 55000 and row(r, "Oil")["trigger"] == "distance")
r = run(car(), "2024-06-01,30000,Oil,90,X\n")
t("overdue by time and distance -> CS01", row(r, "Oil")["status"] == "OVERDUE" and ("medium", "CS01") in codes(r))
sched = [{"item": "Brake fluid", "months": 24, "cost": 80, "safety": True}]
r = run(car(schedule=sched), "2023-06-01,30000,Brake fluid,80,X\n2025-01-01,40000,Other,0,X\n")
t("overdue safety item -> CS01 high", ("high", "CS01") in codes(r))
t("item not in schedule -> CS07", ("info", "CS07") in codes(r))
r = run(car(), "2025-01-01,40000,oil change,90,X\n")
t("aliases match case-insensitively", row(r, "Oil")["last"].startswith("2025-01-01"))
r = run(car(), "2025-01-01,40000,Wipers,20,X\n")
t("no record -> counted from new, CS03", ("medium", "CS03") in codes(r) and "no record" in row(r, "Oil")["last"])
r = run(car(), "2025-01-01,40000,Oil,90,X\n", severe=True)
t("severe intervals used", row(r, "Oil")["due_date"] == datetime.date(2025, 7, 1) and ("info", "CS06") in codes(r))
r = run(car(), "2024-01-01,30000,Oil,90,X\n2024-06-01,20000,Oil,90,X\n2025-01-01,40000,Oil,90,X\n")
t("odometer going down -> CS04, reading ignored", ("medium", "CS04") in codes(r))
r = run(car(), "2025-01-01,40000,Oil,90,X\n2027-01-01,60000,Oil,90,X\n")
t("future date -> CS04 high", ("high", "CS04") in codes(r))
r = run(car(), "2025-01-01,90000,Oil,90,X\n")
t("reading above current odometer -> CS04 high", ("high", "CS04") in codes(r))
r = run(car(), "")
t("no readings -> default rate + CS09", ("medium", "CS09") in codes(r) and abs(r["rate"] * 365 - 12000) < 1)
r = run(car(), "2025-01-01,40000,Oil,90,X\n2025-04-01,42000,Oil,90,X\n")
t("done early -> CS05", ("info", "CS05") in codes(r))
sched = [{"item": "Test", "months": 12, "first_months": 36, "cost": 50, "legal": True}]
r = run(car(vehicle={"first_registered": "2024-01-01"}, schedule=sched), "2025-01-01,40000,Other,0,X\n")
t("first_months for first test from new", row(r, "Test")["due_date"] == datetime.date(2027, 1, 1))
sched = [{"item": "A", "months": 12, "cost": 100}, {"item": "B", "months": 12, "cost": 50}]
r = run(car(schedule=sched), "2025-02-01,41000,A,0,X\n2025-03-15,42000,B,0,X\n")
t("items within the bundle window share a visit", len(r["visits"][0]["items"]) == 2)
t("horizon repeats yearly items", sum(1 for o in r["occ"] if o["item"] == "A") == 2)
t("12-month and total forecast", r["next12"] == 150 and r["total"] == 300)
sched = [{"item": "Belt", "km": 120000, "months": 96, "cost": 650}]
r = run(car(schedule=sched), "2025-01-01,40000,Other,0,X\n")
t("big-ticket item in horizon -> CS08", ("low", "CS08") in codes(r))
t("add_months clamps month end", add_months(datetime.date(2026, 1, 31), 1) == datetime.date(2026, 2, 28))
try:
load_car(car(schedule=[{"item": "X", "cost": 1}])); ok = False
except InputError:
ok = True
t("item without interval rejected", ok)
try:
load_car(dict(car(), colour="red")); ok = False
except InputError:
ok = True
t("unknown key rejected", ok)
r = run(car(), log)
t("report has verdict, visits and forecast", all(s in report(r, "t") for s in ("**Verdict:", "## Workshop visits", "## Cost forecast")))
for name, ok in tests:
print(("PASS " if ok else "FAIL ") + name)
passed = sum(ok for _, ok in tests)
print("%d/%d passed" % (passed, len(tests)))
return 0 if passed == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Plan car maintenance from a schedule and a service log.")
ap.add_argument("car", nargs="?")
ap.add_argument("log", nargs="?")
ap.add_argument("--odometer", type=float)
ap.add_argument("--today")
ap.add_argument("--severe", action="store_true", default=None, help="use severe-service intervals")
ap.add_argument("--horizon", type=int, help="months to plan (default from car file, else 24)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.car or not a.log:
ap.print_usage(sys.stderr)
return 2
try:
try:
cfg = json.load(open(a.car, encoding="utf-8"))
except json.JSONDecodeError as e:
raise InputError("car file is not valid JSON: %s" % e)
c = load_car(cfg, a.odometer, a.today, a.severe, a.horizon)
res = plan(c, open(a.log, encoding="utf-8-sig").read())
except (OSError, InputError) as e:
print("error: %s" % e, file=sys.stderr)
return 2
if a.json:
print(json.dumps(to_json(res), indent=2))
else:
sys.stdout.write(report(res, a.log))
return 1 if any(f[0] == "high" for f in res["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Calculates sales commission payouts for a period from your comp plan and a deals CSV - quota credit by deal type, split deals, tiered rates with accelerators (marginal or flat), credit and payout caps, SPIFFs, clawbacks for early cancellations, pay on booking or collection, and draws. Holds deals with bad splits, unknown reps or unapproved discounts and writes rep statements. Includes an offline stdlib Python calculator.
---
name: sales-commission-payout-calculator
description: Calculates sales commission payouts for a period from your comp plan and a deals CSV - quota credit by deal type, split deals, tiered rates with accelerators (marginal or flat), credit and payout caps, SPIFFs, clawbacks for early cancellations, pay on booking or collection, and draws. Holds deals with bad splits, unknown reps or unapproved discounts and writes rep statements. Includes an offline stdlib Python calculator.
---
# Sales Commission Payout Calculator
You help sales ops, finance and founders pay commissions that are right the first time. Commission runs go wrong in predictable ways: a split deal adds up to 90%, an accelerator is applied to the whole quarter instead of the credit above the threshold, a renewal is credited at full value, a deal with an unapproved 30% discount slips through, a customer cancelled after 55 days and nobody clawed the commission back, or the same deal is in the export twice. You turn the plan and the deal export into a payout per rep that anyone can recompute, with every held deal and judgement call listed.
You apply the plan the user gives you. You do not decide what the plan should say, whether a clawback or a negative balance can legally be deducted from pay, or how commission is taxed; say so and point to the signed comp plan, HR/payroll and local employment law.
## Files in this skill
- `scripts/commission_calc.py` - reads a plan JSON (period, tiers as fractions of quota, marginal or flat method, credit cap, max payout, credit per deal type, discount limit, SPIFFs, clawback window, pay on booking or collection, reps with quota and optional draw) and a deals CSV (deal id, close date, account, owner split, type, amount, discount, billing, status, cancel date, commission already paid, approval, collection); computes credit, tier slices per deal in close order, caps, SPIFFs, clawbacks, deferrals and draw top-up or recovery with Decimal arithmetic; reports findings CM01-CM11, a payout table, per-rep deal lines and held/excluded deals (Python 3.8+ standard library only)
- `references/plan-mechanics.md` - tiers, marginal vs flat, caps, credit rules, splits, SPIFFs, clawbacks, draws, timing, with worked numbers
- `references/checks-and-codes.md` - every finding code, its severity and what to do about it, and the input fields
- `templates/commission-statement.md` - the statement each rep receives and signs off
- `examples/comp-plan.json` - an AE plan for October 2026: 4 tiers (5/8/12/15%), credit cap 200%, max payout 8,000, renewals at 50% credit, 20% discount limit, annual-prepay SPIFF, 90-day clawback, three reps, one with a recoverable draw
- `examples/deals.csv` - 15 rows: normal deals, a 60/40 split, a renewal, an approved and an unapproved big discount, a split that adds up to 90%, a deal for an unknown rep with a thousands separator, two cancellations (inside and outside the clawback window), a September deal and a duplicate row
- `examples/payout-report.md` - the real calculator output on the example
## Workflow
### 1. Collect the plan and the deals
Ask for the comp plan document (or the relevant page) and turn it into a plan JSON like `examples/comp-plan.json`. Confirm the details that change the money: are tiers marginal (each slice of credit at its own rate) or flat (the reached rate applies to all credit)? Is attainment measured on bookings, ACV or margin? How are renewals and expansions credited? Is there a credit cap or a payout cap? When is commission earned: at booking or when the customer pays? How long is the clawback window and does it cover the full commission? Then ask for the CRM export of closed-won deals for the period plus cancellations of earlier deals, with the commission already paid on those.
### 2. Run the calculator
```
python3 scripts/commission_calc.py examples/comp-plan.json examples/deals.csv
python3 scripts/commission_calc.py plan.json deals.csv --rep ana
python3 scripts/commission_calc.py plan.json deals.csv --json
python3 scripts/commission_calc.py --self-test
```
Exit 0 = no high findings, 1 = high findings (deals held that need a decision), 2 = bad input. Without a shell, follow `references/plan-mechanics.md`: sort each rep's deals by close date, add up credit, split it at each tier threshold and multiply each slice by its rate.
### 3. Read the result
- High first: CM01 (unknown rep, bad amount or date, unknown deal type) and CM02 (split not 100%). These deals are held - get the answer from sales ops before payroll.
- Medium: CM05 (discount over the limit without approval - held), CM04 (duplicate row), CM08 (clawback applied - check the amount paid), CM11 (clawbacks bigger than earnings - negative balance).
- Low/info: deals from other periods (CM03), cancellations outside the window, caps (CM06), accelerators (CM07), deferrals (CM09), draws (CM10).
- Check one rep by hand against the per-deal rate slices before sending anything.
### 4. Resolve and re-run
For every held deal record the decision (correct split, approval found, rep assignment) in the CSV and re-run. Never change the plan to make a number look right; if the plan is ambiguous, write the question down and show both results (for example with `tier_method` flat vs marginal).
### 5. Deliver
Return the payout table for approval, a statement per rep from `templates/commission-statement.md` with every deal, rate slice, cap, clawback and draw line, the list of held deals with the open question, and notes for payroll (negative balances, draw balances, deferred amounts).
## Example result
On the example October payouts total 9,500.00 EUR, with 3 deals held. Ana Ruiz reaches 158.7% of her 60,000 quota (renewal Delta Foods credited at 50%), earns 8,280.00 on marginal tiers plus 400.00 in annual-prepay SPIFFs and is capped at the 8,000.00 maximum. Ben Okafor earns 690.00 but the Ironclad deal cancelled 55 days after closing claws back 960.00, so he is paid 0 and carries -270.00 forward. Chloe Martin earns 1,050.00, below her 1,500.00 recoverable draw, so the draw tops up 450.00 and her balance rises to 1,050.00. Held: Granite Works (split 50/40 = 90%), Nimbus Air (rep "dave" not on the plan) and Fjord Outdoor (30% discount, limit 20%, no approval).
## Rules
- Follow the signed plan; when the plan and the data disagree, hold the deal and ask - never guess a split, a rep or an approval.
- Show the arithmetic: every payout must be traceable to deal credit, tier slices and adjustments.
- Apply tiers in close-date order and say whether the plan is marginal or flat.
- Treat clawbacks and negative balances carefully: state what the plan allows and point to HR/payroll and local law before deducting from pay.
- Commission data is confidential: share each rep only their own statement.
- This is a calculation aid, not legal, tax or payroll advice.
FILE:references/plan-mechanics.md
# Plan mechanics with worked numbers
All examples: quota 10,000; tiers 5% up to 50% of quota, 8% up to 100%, 12% above.
## Quota credit
- Credit = deal amount x credit rate for the deal type (`deal_type_credit`, e.g. new 1.0, expansion 1.0, renewal 0.5) x the rep's split share.
- Use the measure the plan names (bookings, first-year ACV, TCV, margin) and export that column as `amount`.
- Attainment = total credit / quota.
## Marginal tiers (default)
Each slice of credit is paid at the rate of the band it falls in, in close-date order.
- 12,000 credit: 5,000 x 5% = 250, 5,000 x 8% = 400, 2,000 x 12% = 240 -> 890.
- Two deals of 6,000: the first earns 5,000 x 5% + 1,000 x 8% = 330, the second 4,000 x 8% + 2,000 x 12% = 560. The total is the same in any order; the per-deal split depends on close order, so keep close dates accurate.
## Flat (retroactive) tiers
The rate of the tier reached applies to all credit: 12,000 x 12% = 1,440. Flat tiers create big jumps at each threshold (9,999 credit earns 799.92, 10,000 earns 1,200 under a 8%/12% flat plan) and invite deal timing games; check the plan wording ("on all bookings" vs "on bookings above").
## Accelerators and decelerators
- An accelerator is simply a higher rate above 100% (12-15% here). A decelerator is a lower rate above a cap point, modelled as a final tier with a lower rate.
## Caps
- Credit cap (`credit_cap_attainment`, e.g. 2.0): credit above 200% of quota earns 0%.
- Payout cap (`max_payout`): commission + SPIFFs for the period cannot exceed this amount. Many plans avoid caps; if yours has one, say so in the statement.
## Splits
- `owner_split` like `ana:60;ben:40` must add up to 100%. Each rep gets their share of credit and SPIFFs. Overlay roles (SE, partner manager) that get credit on top of 100% need their own plan file.
## SPIFFs
- Fixed bonuses for deals matching all `when` fields (for example `type=new` and `billing=annual`). Split with the deal.
## Discount limits
- Deals with `discount_pct` above `max_discount_pct` need `approved=yes`; otherwise the credit is held (`unapproved_discount: hold`) or paid with a finding (`pay`).
## Clawbacks
- A deal closed in an earlier period and cancelled in this period within `clawback.window_days` of closing claws back `share` x the commission paid on it (column `commission_paid`; if missing it is estimated at the first tier rate and flagged).
- Cancelled after the window: no clawback, noted.
- Closed and cancelled in the same period: never credited.
- If clawbacks exceed earnings the payout is 0 and the negative balance is carried forward (CM11). Whether it may be deducted from future pay depends on the plan and local law.
## Booking vs collection
- `pay_on: booking`: commission is paid in the close period.
- `pay_on: collection`: credit counts toward attainment now, but commission (and SPIFF) on deals with `collected` not yes is deferred until the customer pays. Deferred amounts are listed; pay them in the period the cash arrives.
## Draws
- Recoverable draw: the rep is paid at least the draw; if earnings are lower, the top-up is added to the draw balance; later surpluses above the draw repay that balance first.
- Draw 1,500, balance 600, earned 1,050 -> paid 1,500, balance 1,050.
- Next month earned 2,900 -> 2,900 - 1,500 = 1,400 available, 1,050 recovered, paid 1,850, balance 0.
- Non-recoverable draw: a guaranteed minimum; no balance.
## Order of operations used by the calculator
1. Credit per deal (type credit x split) for won deals closed in the period, minus held and excluded deals.
2. Tier slices per deal in close order (marginal) or the reached rate on all credit (flat), with the credit cap.
3. + SPIFFs, then the payout cap.
4. - Deferred (pay on collection), - clawbacks, + carry-in.
5. Draw top-up or recovery; negative results become a carry-forward.
FILE:references/checks-and-codes.md
# Checks and finding codes
| Code | Severity | Check | What to do |
|---|---|---|---|
| CM01 | high: unknown rep, missing rep, bad amount/date, deal type with no credit rate; medium: zero or negative amount | data needed to pay the deal | fix the CRM record or add the rep/type to the plan; held deals are not paid |
| CM02 | high | owner_split shares add up to 100% | get the split confirmed by sales ops / the deal desk |
| CM03 | low | won deal closed outside the period | make sure it was paid in its own period; late-booked deals need a manual adjustment |
| CM04 | medium | the same deal_id appears twice | the second row is ignored; fix the export |
| CM05 | medium | discount above `max_discount_pct` without `approved=yes` | find the approval or keep the credit on hold |
| CM06 | info | credit cap or payout cap applied | mention it on the statement |
| CM07 | info | accelerator tier reached | check the threshold maths once by hand |
| CM08 | medium: clawback applied (estimated if commission_paid missing); low: cancelled after the window; info: closed and cancelled in this period | cancellations | confirm the amount paid originally |
| CM09 | low | commission deferred until collection | pay it in the period the cash arrives |
| CM10 | info | draw top-up or recovery, with the new balance | carry the balance into next period's plan file |
| CM11 | medium | clawbacks exceed earnings: payout 0, negative carry-forward | check the plan and local law before deducting |
## Verdict
- **High findings**: deals are held; resolve before payroll. The payable total shown excludes held deals.
- **Ready for approval**: nothing blocks the run; review medium findings with sales ops.
## Plan JSON keys
`plan_name, period{start, end, label}, currency, tier_method (marginal|flat), tiers[{up_to, rate}] (fractions; last tier without up_to), credit_cap_attainment, max_payout, deal_type_credit{type: rate}, max_discount_pct, unapproved_discount (hold|pay), spiffs[{name, when{column: value}, amount}], clawback{window_days, share}, pay_on (booking|collection), reps{id: {name, quota, draw{amount, recoverable, balance}, carry_forward}}`.
## Deals CSV columns
`deal_id, close_date, account, owner_split (ana or ana:60;ben:40), type, amount, discount_pct, billing, status (won|cancelled), cancel_date, commission_paid, approved (yes/no), collected (yes/no)`. Thousands separators (12,500) and decimal commas (1.234,50) are accepted. Extra columns can be used in SPIFF `when` conditions.
FILE:templates/commission-statement.md
# Commission statement: <rep name>
**Period:** <October 2026> **Plan:** <plan name, version/date signed> **Quota:** <60,000.00 EUR>
**Prepared by:** <name, sales ops> **Date:** <YYYY-MM-DD>
## Summary
| | Amount |
|---|---|
| Quota credit | <95,200.00> (<158.7%> of quota) |
| Commission (tiers) | <8,280.00> |
| SPIFFs | <400.00> |
| Cap | <-680.00> (max payout <8,000.00>) |
| Clawbacks | <-0.00> |
| Deferred to collection | <0.00> |
| Draw top-up / recovery | <0.00> |
| **Payout this period** | **<8,000.00>** |
| Balance carried forward | <0.00> |
## Deals
| Deal | Account | Closed | Type | Credit | Rate slices | Commission | SPIFF |
|---|---|---|---|---|---|---|---|
| <D-1001> | <Acme Logistics> | <2026-10-03> | <new> | <18,000.00> | <18,000.00 x 5.0%> | <900.00> | <200.00> |
## Adjustments
- <Clawback D-0950 cancelled 55 days after close: -960.00>
- <Held: D-1006 Fjord Outdoor, discount 30% awaiting approval - will be paid in the period it is approved>
## How it was calculated
<Marginal tiers 5% to 50% of quota, 8% to 100%, 12% to 150%, 15% above, applied in close-date order; renewals credited at 50%; credit cap 200%; max payout 8,000.>
Please review by <date>. Questions or disputes: <contact>. Disputes must name the deal and the line.
*Calculated from the plan and CRM data listed above. The signed compensation plan governs; this statement is not tax or legal advice.*
FILE:examples/comp-plan.json
{
"plan_name": "Northlake Software AE plan FY2026",
"period": {"start": "2026-10-01", "end": "2026-10-31", "label": "October 2026"},
"currency": "EUR",
"tier_method": "marginal",
"tiers": [
{"up_to": 0.5, "rate": 0.05},
{"up_to": 1.0, "rate": 0.08},
{"up_to": 1.5, "rate": 0.12},
{"rate": 0.15}
],
"credit_cap_attainment": 2.0,
"max_payout": 8000,
"deal_type_credit": {"new": 1.0, "expansion": 1.0, "renewal": 0.5},
"max_discount_pct": 20,
"unapproved_discount": "hold",
"spiffs": [
{"name": "Annual prepay on new logo", "when": {"type": "new", "billing": "annual"}, "amount": 200}
],
"clawback": {"window_days": 90, "share": 1.0},
"pay_on": "booking",
"reps": {
"ana": {"name": "Ana Ruiz", "quota": 60000},
"ben": {"name": "Ben Okafor", "quota": 50000},
"chloe": {"name": "Chloe Martin", "quota": 40000, "draw": {"amount": 1500, "recoverable": true, "balance": 600}}
}
}
FILE:examples/deals.csv
deal_id,close_date,account,owner_split,type,amount,discount_pct,billing,status,cancel_date,commission_paid,approved,collected
D-1001,2026-10-03,Acme Logistics,ana,new,18000,10,annual,won,,,,yes
D-1002,2026-10-08,Brightwave Media,ana:60;ben:40,expansion,12000,0,monthly,won,,,,yes
D-1003,2026-10-14,Corvid Health,ana,new,30000,15,annual,won,,,,no
D-1004,2026-10-21,Delta Foods,ana,renewal,24000,5,annual,won,,,,yes
D-1005,2026-10-28,Evergreen Bank,ana,new,28000,25,monthly,won,,,yes,no
D-1006,2026-10-10,Fjord Outdoor,ben,new,22000,30,annual,won,,,,yes
D-1007,2026-10-17,Granite Works,ben:50;chloe:40,new,15000,10,monthly,won,,,,yes
D-1008,2026-10-24,Harbor Clinics,ben,expansion,9000,0,monthly,won,,,,yes
D-0950,2026-08-12,Ironclad Security,ben,new,16000,10,annual,cancelled,2026-10-06,960,,
D-0871,2026-05-20,Juniper Retail,ben,new,14000,5,monthly,cancelled,2026-10-15,700,,
D-1009,2026-10-09,Kestrel Labs,chloe,new,11000,10,annual,won,,,,yes
D-1010,2026-10-30,Lumen Studio,chloe,new,6000,0,monthly,won,,,,no
D-0990,2026-09-29,Meridian Travel,chloe,new,8000,5,monthly,won,,,,yes
D-1009,2026-10-09,Kestrel Labs,chloe,new,11000,10,annual,won,,,,yes
D-1012,2026-10-19,Nimbus Air,dave,new,"12,500",10,annual,won,,,,yes
FILE:examples/payout-report.md
# Commission payouts: Northlake Software AE plan FY2026, October 2026
Period 2026-10-01 to 2026-10-31, marginal tiers (5.0% up to 50.0% of quota, 8.0% up to 100.0% of quota, 12.0% up to 150.0% of quota, 15.0% above), pay on booking, credit cap 200.0% of quota, max payout 8,000.00. Source: examples/deals.csv.
**Verdict: 2 high finding(s), 3 deal(s) held - resolve them before payroll; total payable now 9,500.00 EUR**
Findings: 2 high, 4 medium, 2 low, 3 info. Figures follow the plan file you provided; the signed comp plan and local pay law win if they differ.
## Payouts
| Rep | Quota | Credit | Attainment | Tier | Commission | SPIFFs | Cap | Clawbacks | Deferred | Draw | Payout |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Ana Ruiz | 60,000.00 | 95,200.00 | 158.7% | 4 | 8,280.00 | 400.00 | -680.00 | | | | **8,000.00** |
| Ben Okafor | 50,000.00 | 13,800.00 | 27.6% | 1 | 690.00 | 0.00 | | -960.00 | | | **0.00** |
| Chloe Martin | 40,000.00 | 17,000.00 | 42.5% | 1 | 850.00 | 200.00 | | | | +450.00 | **1,500.00** |
| **Total** | | | | | | | | | | | **9,500.00** |
## Ana Ruiz
| Deal | Account | Closed | Type | Amount | Share | Credit | Attainment after | Rate slices | Commission | SPIFF |
|---|---|---|---|---|---|---|---|---|---|---|
| D-1001 | Acme Logistics | 2026-10-03 | new | 18,000.00 | 100% | 18,000.00 | 30.0% | 18,000.00 x 5.0% | 900.00 | 200.00 |
| D-1002 | Brightwave Media | 2026-10-08 | expansion | 12,000.00 | 60% | 7,200.00 | 42.0% | 7,200.00 x 5.0% | 360.00 | |
| D-1003 | Corvid Health | 2026-10-14 | new | 30,000.00 | 100% | 30,000.00 | 92.0% | 4,800.00 x 5.0%; 25,200.00 x 8.0% | 2,256.00 | 200.00 |
| D-1004 | Delta Foods | 2026-10-21 | renewal | 24,000.00 | 100% | 12,000.00 | 112.0% | 4,800.00 x 8.0%; 7,200.00 x 12.0% | 1,248.00 | |
| D-1005 | Evergreen Bank | 2026-10-28 | new | 28,000.00 | 100% | 28,000.00 | 158.7% | 22,800.00 x 12.0%; 5,200.00 x 15.0% | 3,516.00 | |
## Ben Okafor
| Deal | Account | Closed | Type | Amount | Share | Credit | Attainment after | Rate slices | Commission | SPIFF |
|---|---|---|---|---|---|---|---|---|---|---|
| D-1002 | Brightwave Media | 2026-10-08 | expansion | 12,000.00 | 40% | 4,800.00 | 9.6% | 4,800.00 x 5.0% | 240.00 | |
| D-1008 | Harbor Clinics | 2026-10-24 | expansion | 9,000.00 | 100% | 9,000.00 | 27.6% | 9,000.00 x 5.0% | 450.00 | |
- Clawback D-0950 (Ironclad Security, cancelled 55 days after close): -960.00
- Negative balance carried forward: -270.00
## Chloe Martin
| Deal | Account | Closed | Type | Amount | Share | Credit | Attainment after | Rate slices | Commission | SPIFF |
|---|---|---|---|---|---|---|---|---|---|---|
| D-1009 | Kestrel Labs | 2026-10-09 | new | 11,000.00 | 100% | 11,000.00 | 27.5% | 11,000.00 x 5.0% | 550.00 | 200.00 |
| D-1010 | Lumen Studio | 2026-10-30 | new | 6,000.00 | 100% | 6,000.00 | 42.5% | 6,000.00 x 5.0% | 300.00 | |
- Draw: top-up 450.00, recovered 0.00, outstanding balance 1,050.00
## Held deals (need a decision)
| Deal | Account | Reason |
|---|---|---|
| D-1006 | Fjord Outdoor | discount 30% not approved |
| D-1007 | Granite Works | split 90% |
| D-1012 | Nimbus Air | unknown rep dave |
## Not counted this period
| Deal | Account | Reason |
|---|---|---|
| D-0871 | Juniper Retail | cancelled after clawback window |
| D-0990 | Meridian Travel | closed 2026-09-29 |
| D-1009 | Kestrel Labs | duplicate row |
## Findings
- **HIGH CM01** D-1012: unknown rep(s) dave in owner_split - deal held
- **HIGH CM02** D-1007: split shares add up to 90%, not 100% - deal held until sales ops confirms the split
- **MEDIUM CM04** D-1009 appears again on row 15 (first on row 12) - second row ignored
- **MEDIUM CM05** D-1006: discount 30% over the 20% limit without approval - credit held
- **MEDIUM CM08** D-0950 cancelled 55 days after close (window 90): clawback 960.00
- **MEDIUM CM11** Ben Okafor: clawbacks exceed earnings by 270.00 - payout 0, negative balance carried to next period (check your plan and local pay law before deducting)
- **LOW CM03** D-0990 closed 2026-09-29, outside October 2026 - not counted here (check it was paid in its own period)
- **LOW CM08** D-0871 cancelled 148 days after close (window 90) - no clawback
- **INFO CM06** Ana Ruiz: commission + SPIFFs 8,680.00 capped at max_payout 8,000.00 (-680.00)
- **INFO CM07** Ana Ruiz at 158.7% of quota - tier 4 (15.0%) reached for the credit above each threshold
- **INFO CM10** Chloe Martin: earned 1,050.00 is below the 1,500.00 draw - draw tops up 450.00 (recoverable: balance now 1,050.00)
Recompute check: commission per deal = sum of (credit in each tier band x tier rate); payout = commission + SPIFFs - cap - deferred - clawbacks + carry-in, then draw rules. Have sales ops and payroll approve before paying.
FILE:scripts/commission_calc.py
#!/usr/bin/env python3
"""Sales commission payout calculator (stdlib only, Python 3.8+).
Reads a compensation plan (JSON) and a deals export (CSV) and works out each rep's payout for
one period: quota credit by deal type, deal splits, tiered rates with accelerators (marginal or
flat/retroactive), credit and payout caps, SPIFFs, clawbacks for deals cancelled inside the
clawback window, payment on booking or on collection, and recoverable or non-recoverable draws.
Deals with problems (bad split, unknown rep, discount over the limit without approval,
duplicates, wrong period) are held or excluded and listed with a finding code.
Usage:
commission_calc.py PLAN.json DEALS.csv [--rep ID]... [--json]
commission_calc.py --self-test
Exit: 0 = no high findings, 1 = high findings (deals held that need a decision), 2 = bad input.
"""
import argparse, csv, datetime, io, json, sys
from collections import OrderedDict
from decimal import Decimal, ROUND_HALF_UP
SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
CENT = Decimal("0.01")
PLAN_KEYS = {"plan_name", "period", "currency", "tier_method", "tiers", "credit_cap_attainment", "max_payout",
"deal_type_credit", "max_discount_pct", "unapproved_discount", "spiffs", "clawback", "pay_on", "reps"}
class InputError(Exception):
pass
def D(x, what="number"):
if isinstance(x, Decimal):
return x
s = str(x).strip().replace(" ", "")
if s == "":
raise InputError("empty %s" % what)
if "," in s and "." in s:
s = s.replace(",", "") if s.rfind(".") > s.rfind(",") else s.replace(".", "").replace(",", ".")
elif "," in s:
s = s.replace(",", "") if len(s.split(",")[-1]) == 3 else s.replace(",", ".")
try:
return Decimal(s)
except Exception:
raise InputError("%s is not a number: %r" % (what, x))
def pdate(s, what):
try:
return datetime.date.fromisoformat(str(s).strip())
except ValueError:
raise InputError("%s must be YYYY-MM-DD, got %r" % (what, s))
def money(x):
return Decimal(x).quantize(CENT, rounding=ROUND_HALF_UP)
def fm(x):
x = money(x)
return "{:,.2f}".format(x)
def num(x):
return "{:f}".format(Decimal(x).normalize())
def pct(x):
return "%.1f%%" % (float(x) * 100)
# ---------------------------------------------------------------- plan
def load_plan(cfg):
if not isinstance(cfg, dict):
raise InputError("plan must be a JSON object")
bad = set(cfg) - PLAN_KEYS
if bad:
raise InputError("unknown plan key(s): %s" % ", ".join(sorted(bad)))
per = cfg.get("period") or {}
p = {"name": cfg.get("plan_name", "Commission plan"), "currency": cfg.get("currency", ""),
"start": pdate(per.get("start"), "period.start"), "end": pdate(per.get("end"), "period.end"),
"label": per.get("label") or "%s..%s" % (per.get("start"), per.get("end")),
"method": cfg.get("tier_method", "marginal"), "pay_on": cfg.get("pay_on", "booking"),
"cap_att": D(cfg["credit_cap_attainment"]) if cfg.get("credit_cap_attainment") is not None else None,
"max_payout": D(cfg["max_payout"]) if cfg.get("max_payout") is not None else None,
"type_credit": {k.lower(): D(v) for k, v in (cfg.get("deal_type_credit") or {"new": 1}).items()},
"max_disc": D(cfg["max_discount_pct"]) if cfg.get("max_discount_pct") is not None else None,
"unapproved": cfg.get("unapproved_discount", "hold"),
"spiffs": cfg.get("spiffs", []),
"claw_days": int((cfg.get("clawback") or {}).get("window_days", 0)),
"claw_share": D((cfg.get("clawback") or {}).get("share", 1)),
"reps": OrderedDict()}
if p["end"] < p["start"]:
raise InputError("period ends before it starts")
if p["method"] not in ("marginal", "flat"):
raise InputError("tier_method must be 'marginal' or 'flat'")
if p["pay_on"] not in ("booking", "collection"):
raise InputError("pay_on must be 'booking' or 'collection'")
if p["unapproved"] not in ("hold", "pay"):
raise InputError("unapproved_discount must be 'hold' or 'pay'")
tiers, prev = [], Decimal(0)
for i, t in enumerate(cfg.get("tiers") or []):
up = D(t["up_to"], "tier up_to") if t.get("up_to") is not None else None
if up is not None and up <= prev:
raise InputError("tier %d: up_to must increase (attainment as a fraction, e.g. 1.0 = 100%%)" % (i + 1))
if up is None and i != len(cfg["tiers"]) - 1:
raise InputError("only the last tier may omit up_to")
tiers.append((prev, up, D(t["rate"], "tier rate")))
prev = up if up is not None else prev
if not tiers:
raise InputError("plan needs at least one tier")
if any(r > 1 for _, _, r in tiers):
raise InputError("tier rates are fractions (0.08 = 8%)")
p["tiers"] = tiers
for rid, r in (cfg.get("reps") or {}).items():
dr = r.get("draw")
p["reps"][rid.lower()] = {"id": rid.lower(), "name": r.get("name", rid), "quota": D(r["quota"], "quota of %s" % rid),
"draw": D(dr["amount"]) if dr else Decimal(0), "recoverable": bool(dr and dr.get("recoverable", True)),
"balance": D(dr.get("balance", 0)) if dr else Decimal(0),
"carry": D(r.get("carry_forward", 0))}
if p["reps"][rid.lower()]["quota"] <= 0:
raise InputError("quota of %s must be positive" % rid)
if not p["reps"]:
raise InputError("plan needs at least one rep")
return p
def rate_at(p, att):
for lo, up, r in p["tiers"]:
if up is None or att < up:
return r
return p["tiers"][-1][2]
def tier_name(p, att):
for i, (lo, up, r) in enumerate(p["tiers"], 1):
if up is None or att < up:
return i
return len(p["tiers"])
# ---------------------------------------------------------------- deals
def parse_split(s, default):
s = (s or "").strip()
if not s:
return OrderedDict([(default, Decimal(100))]) if default else None
out = OrderedDict()
for part in s.replace("|", ";").split(";"):
part = part.strip()
if not part:
continue
if ":" in part:
who, share = part.split(":", 1)
out[who.strip().lower()] = out.get(who.strip().lower(), Decimal(0)) + D(share.strip().rstrip("%"), "split share")
else:
out[part.lower()] = Decimal(100)
return out
def yes(v):
return str(v or "").strip().lower() in ("yes", "y", "true", "1", "x")
def compute(p, deals_text, only=None):
F = []
rows = list(csv.DictReader(io.StringIO(deals_text)))
if not rows:
raise InputError("deals CSV has no rows")
need = {"deal_id", "close_date", "amount"}
if not need <= set(rows[0]):
raise InputError("deals CSV needs columns %s (has %s)" % (", ".join(sorted(need)), ", ".join(rows[0])))
credited = {rid: [] for rid in p["reps"]}
held, excluded, claws, seen = [], [], [], {}
for n, r in enumerate(rows, 2):
did = (r.get("deal_id") or "").strip() or "row %d" % n
acct = r.get("account", "")
try:
close = pdate(r.get("close_date"), "close_date")
amount = D(r.get("amount"), "amount")
disc = D(r.get("discount_pct") or 0, "discount_pct")
except InputError as e:
F.append(("high", "CM01", "%s (row %d): %s - deal not counted" % (did, n, e), did)); held.append((did, acct, "bad data")); continue
key = did.lower()
if key in seen:
F.append(("medium", "CM04", "%s appears again on row %d (first on row %d) - second row ignored" % (did, n, seen[key]), did)); excluded.append((did, acct, "duplicate row")); continue
seen[key] = n
status = (r.get("status") or "won").strip().lower()
split = parse_split(r.get("owner_split") or r.get("rep"), None)
if not split:
F.append(("high", "CM01", "%s: no rep / owner_split - deal not counted" % did, did)); held.append((did, acct, "no rep")); continue
unknown = [w for w in split if w not in p["reps"]]
if status == "cancelled":
cdate = r.get("cancel_date")
try:
cdate = pdate(cdate, "cancel_date") if cdate else None
except InputError as e:
F.append(("high", "CM01", "%s: %s" % (did, e), did)); continue
if not cdate or not (p["start"] <= cdate <= p["end"]):
excluded.append((did, acct, "cancelled outside this period")); continue
if p["start"] <= close <= p["end"]:
F.append(("info", "CM08", "%s closed and was cancelled in this period - not credited" % did, did)); excluded.append((did, acct, "cancelled in period")); continue
age = (cdate - close).days
if age > p["claw_days"]:
F.append(("low", "CM08", "%s cancelled %d days after close (window %d) - no clawback" % (did, age, p["claw_days"]), did)); excluded.append((did, acct, "cancelled after clawback window")); continue
paid = r.get("commission_paid")
if paid and str(paid).strip():
base = D(paid, "commission_paid"); est = False
else:
tc = p["type_credit"].get((r.get("type") or "new").strip().lower(), Decimal(1))
base = amount * tc * p["tiers"][0][2]; est = True
for who, share in split.items():
if who in p["reps"]:
amt = money(base * p["claw_share"] * share / 100)
claws.append({"rep": who, "deal": did, "account": acct, "amount": amt, "age": age, "estimated": est})
F.append(("medium", "CM08", "%s cancelled %d days after close (window %d): clawback %s%s" % (did, age, p["claw_days"], fm(base * p["claw_share"]), " (estimated at the first tier rate - commission_paid missing)" if est else ""), did))
continue
if status not in ("won", "closed_won", "closed won"):
excluded.append((did, acct, "status %s" % status)); continue
if not (p["start"] <= close <= p["end"]):
F.append(("low", "CM03", "%s closed %s, outside %s - not counted here (check it was paid in its own period)" % (did, close, p["label"]), did)); excluded.append((did, acct, "closed %s" % close)); continue
if unknown:
F.append(("high", "CM01", "%s: unknown rep(s) %s in owner_split - deal held" % (did, ", ".join(unknown)), did)); held.append((did, acct, "unknown rep %s" % ", ".join(unknown))); continue
total = sum(split.values())
if total != 100:
F.append(("high", "CM02", "%s: split shares add up to %s%%, not 100%% - deal held until sales ops confirms the split" % (did, num(total)), did)); held.append((did, acct, "split %s%%" % num(total))); continue
dtype = (r.get("type") or "new").strip().lower()
if dtype not in p["type_credit"]:
F.append(("high", "CM01", "%s: deal type '%s' has no credit rate in the plan - deal held" % (did, dtype), did)); held.append((did, acct, "unknown type %s" % dtype)); continue
if amount <= 0:
F.append(("medium", "CM01", "%s: amount %s is not positive - not counted (use a cancellation for reversals)" % (did, amount), did)); excluded.append((did, acct, "amount <= 0")); continue
if p["max_disc"] is not None and disc > p["max_disc"] and not yes(r.get("approved")):
if p["unapproved"] == "hold":
F.append(("medium", "CM05", "%s: discount %s%% over the %s%% limit without approval - credit held" % (did, num(disc), num(p["max_disc"])), did)); held.append((did, acct, "discount %s%% not approved" % num(disc))); continue
F.append(("medium", "CM05", "%s: discount %s%% over the %s%% limit without approval - paid anyway (unapproved_discount=pay)" % (did, num(disc), num(p["max_disc"])), did))
credit_total = amount * p["type_credit"][dtype]
deferred = p["pay_on"] == "collection" and not yes(r.get("collected"))
for who, share in split.items():
spiff = Decimal(0)
for s in p["spiffs"]:
if all(str(r.get(k, "")).strip().lower() == str(v).lower() for k, v in (s.get("when") or {}).items()):
spiff += D(s["amount"]) * share / 100
credited[who].append({"deal": did, "account": acct, "close": close, "type": dtype, "amount": amount, "share": share,
"credit": credit_total * share / 100, "spiff": spiff, "deferred": deferred})
# per rep
reps = []
for rid, rep in p["reps"].items():
if only and rid not in only:
continue
ds = sorted(credited[rid], key=lambda d: (d["close"], d["deal"]))
q = rep["quota"]
cap_credit = q * p["cap_att"] if p["cap_att"] is not None else None
cum = Decimal(0)
total_credit = sum((d["credit"] for d in ds), Decimal(0))
att = total_credit / q
flat_rate = rate_at(p, min(att, p["cap_att"]) if p["cap_att"] is not None else att)
for d in ds:
a, b = cum, cum + d["credit"]
cum = b
comm, slices = Decimal(0), []
if p["method"] == "flat":
eligible = b if cap_credit is None else min(b, cap_credit)
part = max(Decimal(0), eligible - a)
comm = part * flat_rate
slices.append("%s x %s" % (fm(part), pct(flat_rate)))
if part < d["credit"]:
slices.append("%s over the credit cap x 0%%" % fm(d["credit"] - part))
else:
for lo, up, rate in p["tiers"]:
lo_c, up_c = lo * q, (up * q if up is not None else None)
if cap_credit is not None:
up_c = cap_credit if up_c is None else min(up_c, cap_credit)
if lo_c >= cap_credit:
continue
x0, x1 = max(a, lo_c), (b if up_c is None else min(b, up_c))
if x1 > x0:
comm += (x1 - x0) * rate
slices.append("%s x %s" % (fm(x1 - x0), pct(rate)))
if cap_credit is not None and b > cap_credit:
slices.append("%s over the credit cap x 0%%" % fm(b - max(a, cap_credit)))
d["commission"] = money(comm)
d["slices"] = slices
d["att_after"] = b / q
commission = sum((d["commission"] for d in ds), Decimal(0))
spiffs = money(sum((d["spiff"] for d in ds), Decimal(0)))
earned = commission + spiffs
capped = Decimal(0)
if p["max_payout"] is not None and earned > p["max_payout"]:
capped = earned - p["max_payout"]
F.append(("info", "CM06", "%s: commission + SPIFFs %s capped at max_payout %s (-%s)" % (rep["name"], fm(earned), fm(p["max_payout"]), fm(capped)), rid))
earned = p["max_payout"]
if cap_credit is not None and total_credit > cap_credit:
F.append(("info", "CM06", "%s: credit above %s of quota (%s) earns no commission" % (rep["name"], pct(p["cap_att"]), fm(total_credit - cap_credit)), rid))
if att >= 1:
F.append(("info", "CM07", "%s at %s of quota - tier %d (%s) reached%s" % (rep["name"], pct(att), tier_name(p, att), pct(rate_at(p, att)), " and applied to all credit (flat method)" if p["method"] == "flat" else " for the credit above each threshold"), rid))
deferred = money(sum((d["commission"] + d["spiff"] for d in ds if d["deferred"]), Decimal(0)))
if deferred and capped:
deferred = money(deferred * earned / (earned + capped))
if deferred:
F.append(("low", "CM09", "%s: %s deferred until the customer pays (pay_on collection)" % (rep["name"], fm(deferred)), rid))
claw = sum((c["amount"] for c in claws if c["rep"] == rid), Decimal(0))
payable = earned - deferred - claw + rep["carry"]
draw_paid, recovered, bal = Decimal(0), Decimal(0), rep["balance"]
if rep["draw"]:
if payable < rep["draw"]:
draw_paid = rep["draw"] - max(payable, Decimal(0))
if rep["recoverable"]:
bal += draw_paid
F.append(("info", "CM10", "%s: earned %s is below the %s draw - draw tops up %s%s" % (rep["name"], fm(max(payable, Decimal(0))), fm(rep["draw"]), fm(draw_paid), (" (recoverable: balance now %s)" % fm(bal)) if rep["recoverable"] else " (non-recoverable)"), rid))
payout = rep["draw"]
if payable < 0:
bal += -payable if rep["recoverable"] else 0
else:
recovered = min(bal, payable - rep["draw"]) if rep["recoverable"] else Decimal(0)
bal -= recovered
payout = payable - recovered
if recovered:
F.append(("info", "CM10", "%s: %s of the outstanding draw recovered (balance now %s)" % (rep["name"], fm(recovered), fm(bal)), rid))
else:
payout = payable
carry_out = Decimal(0)
if payout < 0:
carry_out = payout
F.append(("medium", "CM11", "%s: clawbacks exceed earnings by %s - payout 0, negative balance carried to next period (check your plan and local pay law before deducting)" % (rep["name"], fm(-payout)), rid))
payout = Decimal(0)
reps.append({"id": rid, "name": rep["name"], "quota": q, "credit": total_credit, "attainment": att,
"tier": tier_name(p, att), "commission": commission, "spiffs": spiffs, "capped": capped,
"clawbacks": claw, "deferred": deferred, "carry_in": rep["carry"], "draw_topup": draw_paid,
"draw_recovered": recovered, "draw_balance": bal, "carry_out": carry_out, "payout": money(payout), "deals": ds})
F.sort(key=lambda f: (SEV[f[0]], f[1], str(f[3])))
return {"plan": p, "reps": reps, "held": held, "excluded": excluded, "clawbacks": claws, "findings": F}
# ---------------------------------------------------------------- report
def report(res, title):
p, F = res["plan"], res["findings"]
c = {s: sum(1 for f in F if f[0] == s) for s in SEV}
cur = (" " + p["currency"]) if p["currency"] else ""
total = sum((r["payout"] for r in res["reps"]), Decimal(0))
L = ["# Commission payouts: %s, %s" % (p["name"], p["label"]), ""]
L.append("Period %s to %s, %s tiers (%s), pay on %s%s%s. Source: %s." % (
p["start"], p["end"], p["method"], ", ".join("%s%s" % (pct(r), (" up to %s of quota" % pct(u)) if u is not None else " above") for _, u, r in p["tiers"]),
p["pay_on"], (", credit cap %s of quota" % pct(p["cap_att"])) if p["cap_att"] is not None else "",
(", max payout %s" % fm(p["max_payout"])) if p["max_payout"] is not None else "", title))
L.append("")
if c["high"]:
L.append("**Verdict: %d high finding(s), %d deal(s) held - resolve them before payroll; total payable now %s%s**" % (c["high"], len(res["held"]), fm(total), cur))
else:
L.append("**Verdict: ready for approval - total payable %s%s%s**" % (fm(total), cur, " (medium findings to review)" if c["medium"] else ""))
L.append("")
L.append("Findings: %d high, %d medium, %d low, %d info. Figures follow the plan file you provided; the signed comp plan and local pay law win if they differ." % (c["high"], c["medium"], c["low"], c["info"]))
L += ["", "## Payouts", "", "| Rep | Quota | Credit | Attainment | Tier | Commission | SPIFFs | Cap | Clawbacks | Deferred | Draw | Payout |", "|---|---|---|---|---|---|---|---|---|---|---|---|"]
for r in res["reps"]:
draw = ("+%s" % fm(r["draw_topup"])) if r["draw_topup"] else (("-%s recovered" % fm(r["draw_recovered"])) if r["draw_recovered"] else "")
L.append("| %s | %s | %s | %s | %d | %s | %s | %s | %s | %s | %s | **%s** |" % (
r["name"], fm(r["quota"]), fm(r["credit"]), pct(r["attainment"]), r["tier"], fm(r["commission"]), fm(r["spiffs"]),
("-" + fm(r["capped"])) if r["capped"] else "", ("-" + fm(r["clawbacks"])) if r["clawbacks"] else "",
fm(r["deferred"]) if r["deferred"] else "", draw, fm(r["payout"])))
L.append("| **Total** | | | | | | | | | | | **%s** |" % fm(total))
for r in res["reps"]:
L += ["", "## %s" % r["name"], ""]
if not r["deals"]:
L.append("No credited deals this period.")
else:
L += ["| Deal | Account | Closed | Type | Amount | Share | Credit | Attainment after | Rate slices | Commission | SPIFF |", "|---|---|---|---|---|---|---|---|---|---|---|"]
for d in r["deals"]:
L.append("| %s | %s | %s | %s | %s | %s%% | %s | %s | %s | %s | %s |" % (
d["deal"], d["account"], d["close"], d["type"], fm(d["amount"]), num(d["share"]), fm(d["credit"]),
pct(d["att_after"]), "; ".join(d["slices"]) or "-", fm(d["commission"]) + (" (deferred)" if d["deferred"] else ""), fm(d["spiff"]) if d["spiff"] else ""))
extra = []
for cl in [x for x in res["clawbacks"] if x["rep"] == r["id"]]:
extra.append("- Clawback %s (%s, cancelled %d days after close): -%s%s" % (cl["deal"], cl["account"], cl["age"], fm(cl["amount"]), " (estimated)" if cl["estimated"] else ""))
if r["carry_in"]:
extra.append("- Carried in from last period: %s" % fm(r["carry_in"]))
if r["draw_topup"] or r["draw_recovered"] or r["draw_balance"]:
extra.append("- Draw: top-up %s, recovered %s, outstanding balance %s" % (fm(r["draw_topup"]), fm(r["draw_recovered"]), fm(r["draw_balance"])))
if r["carry_out"]:
extra.append("- Negative balance carried forward: %s" % fm(r["carry_out"]))
L += [""] + extra if extra else []
if res["held"]:
L += ["", "## Held deals (need a decision)", "", "| Deal | Account | Reason |", "|---|---|---|"]
L += ["| %s | %s | %s |" % h for h in res["held"]]
if res["excluded"]:
L += ["", "## Not counted this period", "", "| Deal | Account | Reason |", "|---|---|---|"]
L += ["| %s | %s | %s |" % h for h in res["excluded"]]
L += ["", "## Findings", ""]
L += ["- **%s %s** %s" % (s.upper(), code, m) for s, code, m, _ in F] or ["- none"]
L += ["", "Recompute check: commission per deal = sum of (credit in each tier band x tier rate); payout = commission + SPIFFs - cap - deferred - clawbacks + carry-in, then draw rules. Have sales ops and payroll approve before paying."]
return "\n".join(L) + "\n"
def to_json(res):
def j(x):
if isinstance(x, Decimal):
return float(money(x))
if isinstance(x, datetime.date):
return x.isoformat()
if isinstance(x, dict):
return {k: j(v) for k, v in x.items()}
if isinstance(x, (list, tuple)):
return [j(v) for v in x]
return x
out = {"period": res["plan"]["label"], "reps": [], "held": [list(h) for h in res["held"]], "excluded": [list(h) for h in res["excluded"]],
"clawbacks": j(res["clawbacks"]), "findings": [{"severity": s, "code": c, "message": m} for s, c, m, _ in res["findings"]]}
for r in res["reps"]:
rr = {k: v for k, v in r.items() if k != "deals"}
rr["attainment"] = round(float(r["attainment"]), 4)
rr = j(rr)
rr["deals"] = [j({k: v for k, v in d.items() if k not in ("att_after",)}) for d in r["deals"]]
out["reps"].append(rr)
return out
# ---------------------------------------------------------------- self-test
def self_test():
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
base = {"period": {"start": "2026-10-01", "end": "2026-10-31"}, "tiers": [{"up_to": 0.5, "rate": 0.05}, {"up_to": 1.0, "rate": 0.08}, {"rate": 0.12}],
"reps": {"a": {"quota": 10000}, "b": {"quota": 10000}}}
H = "deal_id,close_date,owner_split,type,amount,discount_pct,billing,status,cancel_date,commission_paid,approved,collected\n"
def run(rows, **over):
cfg = dict(base); cfg.update(over)
return compute(load_plan(cfg), H + rows)
def rep(res, rid):
return next(r for r in res["reps"] if r["id"] == rid)
def codes(res):
return [(f[0], f[1]) for f in res["findings"]]
r = run("1,2026-10-05,a,new,4000,,,won,,,,\n")
t("below first threshold: 4000 x 5% = 200", rep(r, "a")["commission"] == Decimal("200.00"))
r = run("1,2026-10-05,a,new,12000,,,won,,,,\n")
t("marginal: 5000x5% + 5000x8% + 2000x12% = 890", rep(r, "a")["commission"] == Decimal("890.00"))
r = run("1,2026-10-05,a,new,12000,,,won,,,,\n", tier_method="flat")
t("flat: 12000 x 12% = 1440", rep(r, "a")["commission"] == Decimal("1440.00"))
r = run("1,2026-10-05,a,new,6000,,,won,,,,\n2,2026-10-20,a,new,6000,,,won,,,,\n")
t("tiers follow close order across deals", [d["commission"] for d in rep(r, "a")["deals"]] == [Decimal("330.00"), Decimal("560.00")])
t("accelerator reached -> CM07", ("info", "CM07") in codes(r))
r = run("1,2026-10-05,a,new,30000,,,won,,,,\n", credit_cap_attainment=2.0)
t("credit cap at 200%: 250+400+1200 = 1850", rep(r, "a")["commission"] == Decimal("1850.00") and ("info", "CM06") in codes(r))
r = run("1,2026-10-05,a,new,30000,,,won,,,,\n", max_payout=1000)
t("max_payout caps the payout", rep(r, "a")["payout"] == Decimal("1000.00"))
r = run("1,2026-10-05,a,renewal,10000,,,won,,,,\n", deal_type_credit={"new": 1, "renewal": 0.5})
t("renewal credited at 50%", rep(r, "a")["credit"] == Decimal("5000") and rep(r, "a")["commission"] == Decimal("250.00"))
r = run("1,2026-10-05,a:60;b:40,new,10000,,,won,,,,\n")
t("split 60/40 credits both reps", rep(r, "a")["credit"] == Decimal("6000") and rep(r, "b")["credit"] == Decimal("4000"))
r = run("1,2026-10-05,a:60;b:30,new,10000,,,won,,,,\n")
t("split not 100% -> CM02 high, held", ("high", "CM02") in codes(r) and rep(r, "a")["credit"] == 0 and r["held"])
r = run("1,2026-10-05,zed,new,10000,,,won,,,,\n")
t("unknown rep -> CM01 high", ("high", "CM01") in codes(r))
r = run("1,2026-10-05,a,new,10000,30,,won,,,,\n", max_discount_pct=20)
t("discount over limit unapproved -> CM05 held", ("medium", "CM05") in codes(r) and rep(r, "a")["credit"] == 0)
r = run("1,2026-10-05,a,new,10000,30,,won,,,yes,\n", max_discount_pct=20)
t("approved discount is paid", rep(r, "a")["credit"] == Decimal("10000"))
r = run("1,2026-09-28,a,new,10000,,,won,,,,\n")
t("closed outside period -> CM03, not counted", ("low", "CM03") in codes(r) and rep(r, "a")["credit"] == 0)
r = run("1,2026-10-05,a,new,4000,,,won,,,,\n1,2026-10-05,a,new,4000,,,won,,,,\n")
t("duplicate deal id -> CM04, counted once", ("medium", "CM04") in codes(r) and rep(r, "a")["credit"] == Decimal("4000"))
r = run("1,2026-10-05,a,new,4000,,,won,,,,\n9,2026-08-20,a,new,8000,,,cancelled,2026-10-10,400,,\n", clawback={"window_days": 90})
t("cancelled within window -> clawback of paid commission", rep(r, "a")["clawbacks"] == Decimal("400.00") and rep(r, "a")["payout"] == 0 and rep(r, "a")["carry_out"] == Decimal("-200.00") and ("medium", "CM11") in codes(r))
r = run("9,2026-05-01,a,new,8000,,,cancelled,2026-10-10,400,,\n", clawback={"window_days": 90})
t("cancelled after window -> no clawback", rep(r, "a")["clawbacks"] == 0 and ("low", "CM08") in codes(r))
r = run("9,2026-09-20,a,new,8000,,,cancelled,2026-10-10,,,\n", clawback={"window_days": 90})
t("missing commission_paid -> estimated at first tier", rep(r, "a")["clawbacks"] == Decimal("400.00"))
r = run("1,2026-10-05,a,new,4000,,annual,won,,,,\n", spiffs=[{"name": "Annual", "when": {"billing": "annual"}, "amount": 150}])
t("SPIFF added", rep(r, "a")["spiffs"] == Decimal("150.00") and rep(r, "a")["payout"] == Decimal("350.00"))
r = run("1,2026-10-05,a,new,4000,,,won,,,,no\n", pay_on="collection")
t("pay on collection defers unpaid deals", rep(r, "a")["deferred"] == Decimal("200.00") and rep(r, "a")["payout"] == 0)
cfg = dict(base, reps={"a": {"quota": 10000, "draw": {"amount": 500, "recoverable": True, "balance": 0}}})
res = compute(load_plan(cfg), H + "1,2026-10-05,a,new,4000,,,won,,,,\n")
t("recoverable draw tops up and builds balance", rep(res, "a")["payout"] == Decimal("500.00") and rep(res, "a")["draw_balance"] == Decimal("300"))
cfg = dict(base, reps={"a": {"quota": 10000, "draw": {"amount": 500, "recoverable": True, "balance": 300}}})
res = compute(load_plan(cfg), H + "1,2026-10-05,a,new,12000,,,won,,,,\n")
t("surplus recovers draw balance", rep(res, "a")["draw_recovered"] == Decimal("300") and rep(res, "a")["payout"] == Decimal("590.00"))
cfg = dict(base, reps={"a": {"quota": 10000, "draw": {"amount": 500, "recoverable": False}}})
res = compute(load_plan(cfg), H + "1,2026-10-05,a,new,4000,,,won,,,,\n")
t("non-recoverable draw: no balance", rep(res, "a")["payout"] == Decimal("500.00") and rep(res, "a")["draw_balance"] == 0)
t("thousands separator parsed", D("12,000") == Decimal("12000") and D("1.234,50") == Decimal("1234.50"))
for label, bad in [("tiers out of order", dict(base, tiers=[{"up_to": 1, "rate": 0.05}, {"up_to": 0.5, "rate": 0.08}])),
("rate as percent", dict(base, tiers=[{"rate": 8}])), ("unknown key", dict(base, quotas=1))]:
try:
load_plan(bad); ok = False
except InputError:
ok = True
t("bad plan rejected: " + label, ok)
rr = run("1,2026-10-05,a,new,4000,,,won,,,,\n")
t("report has verdict and payouts table", "**Verdict:" in report(rr, "t") and "## Payouts" in report(rr, "t"))
for name, ok in tests:
print(("PASS " if ok else "FAIL ") + name)
passed = sum(ok for _, ok in tests)
print("%d/%d passed" % (passed, len(tests)))
return 0 if passed == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Calculate sales commission payouts for one period.")
ap.add_argument("plan", nargs="?")
ap.add_argument("deals", nargs="?")
ap.add_argument("--rep", action="append", default=[], help="only show this rep id (repeatable)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.plan or not a.deals:
ap.print_usage(sys.stderr)
return 2
try:
try:
cfg = json.load(open(a.plan, encoding="utf-8"))
except json.JSONDecodeError as e:
raise InputError("plan is not valid JSON: %s" % e)
p = load_plan(cfg)
res = compute(p, open(a.deals, encoding="utf-8-sig").read(), [x.lower() for x in a.rep] or None)
except (OSError, InputError, KeyError) as e:
print("error: %s" % (("missing field %s" % e) if isinstance(e, KeyError) else e), file=sys.stderr)
return 2
if a.json:
print(json.dumps(to_json(res), indent=2))
else:
sys.stdout.write(report(res, a.deals))
return 1 if any(f[0] == "high" for f in res["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Checks a GitHub CODEOWNERS file against the real repository file list - works out who owns every file (last matching line wins), then reports invalid or unknown owners, unowned files and critical paths left to the catch-all rule, rules that match nothing or are fully overridden by later lines, unsupported ! and [ ] syntax, duplicates, empty teams and one-person bottlenecks. Includes an offline stdlib Python checker.
---
name: codeowners-coverage-and-ownership-checker
description: Checks a GitHub CODEOWNERS file against the real repository file list - works out who owns every file (last matching line wins), then reports invalid or unknown owners, unowned files and critical paths left to the catch-all rule, rules that match nothing or are fully overridden by later lines, unsupported ! and [ ] syntax, duplicates, empty teams and one-person bottlenecks. Includes an offline stdlib Python checker.
---
# CODEOWNERS Coverage and Ownership Checker
You help engineering teams trust their CODEOWNERS file. Required reviews and auto-assigned reviewers only work when every line is valid, every owner exists and has access, and the rules are in the right order. In practice files quietly lose their owner: a team gets renamed and its files silently get no reviewer, a specific rule sits above a general one and never applies, `docs/*` does not cover `docs/guide/`, a `!` negation copied from .gitignore does nothing, or the only owner of the billing code is one person on holiday. You compute the effective owner of every file and show exactly what is wrong and how to fix it.
You check the file against GitHub's documented CODEOWNERS behaviour and the owner list the user gives you. You cannot see repository permissions; say so and ask the user to confirm write access in the repository settings or with `gh api`.
## Files in this skill
- `scripts/codeowners_check.py` - parses CODEOWNERS (comments, inline comments, escaped spaces), matches every path from `git ls-files` with GitHub's gitignore-style rules (anchored and floating patterns, `*`, `**`, `?`, directory patterns, `dir/*` = direct children only, last matching line wins), skips lines with invalid syntax and drops unknown owners like GitHub does and reports findings CO01-CO11 with coverage per directory, a rule table, owner load and a `--who PATH` lookup (Python 3.8+ standard library only)
- `references/codeowners-syntax-and-matching.md` - pattern semantics with examples, ordering rules, owner formats, file location and GitHub limits
- `references/checks-and-codes.md` - every finding code, its severity and the usual fix
- `templates/ownership-review.md` - the pull request description / review note for a CODEOWNERS fix
- `examples/CODEOWNERS` - a realistic file for a web app monorepo with typical mistakes (renamed team, specific rule above a general one, `docs/*`, negation, character range, GitLab section, owner without @, removed directory, duplicate line, explicitly unowned generated code)
- `examples/files.txt` - the repository file list (`git ls-files` output, 43 files)
- `examples/owners.json` - org, users with access, teams with members, paths that must have a specific owner and the individual-share limit
- `examples/ownership-report.md` - the real checker output on the example
## Workflow
### 1. Collect the inputs
Ask for the CODEOWNERS file (from `.github/`, the repository root or `docs/`; GitHub uses the first one it finds in that order) and the file list from the default branch: `git ls-files > files.txt`. Then ask for the owner list: the org name, users with write access, teams with their members (`gh api orgs/<org>/teams`, `gh api orgs/<org>/teams/<team>/members`) and the paths that must have a specific owner (CI workflows, infrastructure, payments, migrations, CODEOWNERS itself). Write them into a JSON like `examples/owners.json`. Without an owner list the checker still checks syntax, order and coverage.
### 2. Run the checker
```
python3 scripts/codeowners_check.py examples/CODEOWNERS examples/files.txt --owners examples/owners.json
python3 scripts/codeowners_check.py .github/CODEOWNERS files.txt --owners owners.json --who services/api/billing/invoices.py
python3 scripts/codeowners_check.py .github/CODEOWNERS files.txt --json
python3 scripts/codeowners_check.py --self-test
```
Exit 0 = no high findings, 1 = high findings, 2 = bad input. Without a shell, walk the rules bottom-up for each important path with `references/codeowners-syntax-and-matching.md`: the first line from the bottom that matches is the owner.
### 3. Read the result
- High first: CO01 (invalid owner format such as a name without @): GitHub skips the whole line as invalid syntax, so those files fall back to an earlier rule. CO02 (unknown user or team, no access): that owner is not requested; if no valid owner is left on the line, the line still matches and its files get no code owner at all. CO03 high: files under a must-own path have no owner.
- Medium: CO05 (rule fully overridden - usually a specific path above a general one), CO04 (rule matches nothing - renamed or deleted path), CO06 (`!` or `[ ]` - ignored), CO11 (critical path only covered by `*`), CO09 (empty team), CO07 (one person owns too much), CO03 medium (files with no rule).
- Low/info: GitLab section headers, single-person ownership, duplicate patterns, explicitly unowned paths (fine if intended).
- Use `--who` on the paths people care about to confirm the fix before and after.
### 4. Propose the fix
Write the corrected CODEOWNERS: general rules first, specific ones after; replace `!` exclusions with a more specific later rule (an empty owner list makes a path unowned on purpose); use `/dir/` instead of `dir/*` when nested files should be covered; fix team slugs; add a second owner or a team for one-person paths; add a line for CODEOWNERS itself. Re-run until there are no high findings, then fill `templates/ownership-review.md`.
### 5. Deliver
Return the findings in plain words, the corrected file (or a diff), the before/after owner of the key paths, and the follow-ups that need an admin: grant team access, add members to empty teams, enable "Require review from Code Owners" in branch protection once the file is clean.
## Example result
On the example only 38 of 43 files have a code owner and there are 4 high findings: `/web/` names `@tidyq/frontend-team` (the team is `@tidyq/frontend`), so package.json, App.tsx, Home.tsx and Checkout.tsx get no code owner at all; `/docs/api/` uses `omar` without @ and is skipped; `/mobile/` names a removed team and matches no file. `/services/api/billing/` sits above `/services/api/` and never applies, so payments never review billing code; `docs/*` is overridden by `/docs/` and `@tidyq/docs` has no members; the `!` and `[a-z]` lines are ignored; Terraform modules and dependabot.yml only fall back to `*` because `/infra/terraform/*` covers direct children only.
## Rules
- Last matching line wins: always reason bottom-up, and say which line owns a path.
- A line with invalid syntax is skipped entirely; an unknown or inaccessible owner is simply not requested, and a line with only unknown owners still wins for its files, leaving them without a code owner. Never assume an earlier rule takes over in that case.
- Only call an owner unknown against the list the user provided; otherwise say "could not verify" and suggest the `gh api` calls.
- Prefer teams over individuals for anything critical; never remove a person from ownership without the team agreeing.
- Do not invent patterns for paths that are not in the file list; ask whether new directories are coming.
- Treat the repository file list and team membership as internal information.
FILE:references/codeowners-syntax-and-matching.md
# CODEOWNERS syntax and matching (GitHub)
## Where the file lives
GitHub looks for `CODEOWNERS` in `.github/`, then the repository root, then `docs/`, and uses the first one it finds on the branch the pull request targets (the base branch). Files over 3 MB are ignored. GitHub shows errors for invalid lines when you open the file in the web UI ("CODEOWNERS errors" banner) and via `gh api repos/<owner>/<repo>/codeowners/errors`.
## Line format
```
<pattern> <owner> [<owner> ...] # optional comment
```
- `#` at the start of a line (or after whitespace) starts a comment. Unlike .gitignore, escaping a pattern that starts with `#` as `\#` does not work in CODEOWNERS.
- Paths are case sensitive, even if your laptop's file system is not.
- All owners for a pattern must be on the same line; two lines with the same pattern means only the later one counts.
- Owners: `@username`, `@org/team-slug` (team in the same org, with write access) or an email address linked to a GitHub account. A bare name such as `omar` is invalid.
- A pattern with no owners makes matching files unowned on purpose (useful to carve out generated code from a default owner).
- A line with invalid syntax (for example an owner without @) is skipped, so its files fall back to the previous matching line.
- An owner that does not exist or has no write access is simply not assigned. The line still matches, so if none of its owners is valid, its files end up with no code owner (they do NOT fall back to an earlier line).
## Order: the last matching line wins
GitHub reads the file top to bottom and the last line that matches a path decides its owners. Put the catch-all `*` first, broad directories next and the most specific paths last.
```
* @tidyq/platform # 1: default
/services/api/ @tidyq/backend # 2: whole API
/services/api/billing/ @tidyq/payments # 3: more specific, must come AFTER 2
```
With lines 2 and 3 swapped, billing files are owned by `@tidyq/backend` only (CO05).
## Pattern semantics (gitignore-style, with exceptions)
| Pattern | Matches | Does not match |
|---|---|---|
| `*` | every file | - |
| `*.js` | `app.js`, `web/lib/util.js` (any depth) | `app.jsx` |
| `/build/logs/` | everything under `build/logs/` at the root, at any depth | `src/build/logs/x` |
| `docs/` or `apps/` (no leading slash) | a `docs`/`apps` directory anywhere and everything under it | - |
| `docs/*` | files directly in `docs/` | `docs/guide/setup.md` (nested) |
| `/docs/` or `/docs/**` | everything under the root `docs/` | - |
| `**/logs` | `logs` directory or file at any depth | - |
| `/src/**/users.py` | `src/users.py`, `src/api/v2/users.py` | - |
| `README.md` | `README.md` at any depth | - |
| `/README.md` | only the root `README.md` | `sub/README.md` |
| `src/ap?.py` | `src/app.py` | `src/apps.py` |
Rules of thumb:
- A pattern with a slash at the start or in the middle is anchored to the repository root; one without (apart from a trailing slash) floats and matches at any depth.
- A trailing slash means "this directory and everything in it".
- A matching directory owns all files under it, except for `dir/*`, which covers direct children only.
## Not supported (the line is ignored or flagged)
- `!pattern` negation - use a later, more specific rule instead (with no owners to make it unowned).
- `[a-z]` character ranges - list the paths or use `*`.
- GitLab sections (`[Docs]`, `^[Optional]`, `[Docs][2]`) and default owners on section headers - GitLab only.
## Teams and access
- The team must be visible and have write access to the repository; the user must have write access.
- An empty team gets the review request but nobody is notified.
- Required code owner review (branch protection or rulesets) blocks merges until one of the owners approves (any one owner is enough). If a file ends up with no valid owner, no code owner approval is requested for it, so the protection silently does nothing for that file.
## Getting the inputs
```
git ls-files > files.txt
gh api orgs/<org>/teams --paginate -q '.[].slug'
gh api orgs/<org>/teams/<slug>/members --paginate -q '.[].login'
gh api repos/<owner>/<repo>/collaborators --paginate -q '.[] | select(.permissions.push) | .login'
gh api repos/<owner>/<repo>/codeowners/errors
```
FILE:references/checks-and-codes.md
# Checks and finding codes
| Code | Severity | Check | Typical fix |
|---|---|---|---|
| CO01 | high | owner is not `@user`, `@org/team` or an email (e.g. `omar`); pattern cannot be parsed | add the @ / fix the slug; the whole line is skipped until then |
| CO02 | high | user not in your users list, team not in your teams list, or team from another org (only with `--owners`); a second CO02 line says when no valid owner is left on a line, so its files get no code owner | fix the slug (the checker suggests a close team), give the team write access, or remove the owner |
| CO03 | high: unowned file under a `require_owner` path; medium: other files with no rule or only unknown owners (explicitly unowned files are CO10) | coverage | add a rule, or a catch-all `*` default |
| CO04 | medium | the pattern matches no file in the list | path renamed or deleted: update or remove the line |
| CO05 | medium | every file the rule matches is owned by later lines (last match wins) | move the specific rule below the general one |
| CO06 | medium: `!` negation, `[ ]` ranges (ignored by GitHub); low: GitLab section header | unsupported syntax | use a later more specific rule; empty owner list = unowned |
| CO07 | medium: one individual (not a team) is the effective owner of more than `max_individual_share` of files; low: files owned by a single individual and no team | bus factor | add a team or a second owner |
| CO08 | low | the same pattern appears twice; only the later line counts | merge the owners into one line |
| CO09 | medium | team has no members in your teams list | add members or use another team |
| CO10 | info | pattern with no owners: files intentionally unowned | fine if intended (generated code, vendored files) |
| CO11 | medium | files under a `require_owner` path are only owned by the catch-all `*` rule | add a specific rule for that path |
## Verdict
- **High findings**: fix before relying on "Require review from Code Owners".
- **Medium only**: the file works but some files go to the wrong people.
- **Healthy**: only low/info.
## Owners file (`--owners`, JSON)
| Key | Meaning |
|---|---|
| `org` | org name; teams from another org are flagged |
| `users` | logins with write access (without @) |
| `teams` | `{"org/slug": [members]}` |
| `require_owner` | patterns (same syntax) for paths that must have a specific owner |
| `max_individual_share` | share of files one person may own alone (default 0.4) |
| `ignore` | patterns for files to leave out (vendored code, lock files) |
## Limits of the check
- Repository permissions and team visibility are not checked - confirm with the `gh api` calls in the syntax reference or the "CODEOWNERS errors" banner.
- The file list is a snapshot of one branch; run it against the base branch of your pull requests.
FILE:templates/ownership-review.md
# CODEOWNERS fix: <repository>
**Branch:** <base branch> **Checked:** <YYYY-MM-DD> **Files:** <n> **Owned before / after:** <x% / y%>
## What was wrong
| Line | Rule | Problem | Effect |
|---|---|---|---|
| <11> | `<pattern> <owners>` | <unknown team @org/frontend-team> | <web files fall back to @org/platform> |
| <6> | `<pattern>` | <overridden by line 7> | <payments never review billing> |
## Changes
```diff
<- /services/api/billing/ @org/payments
< /services/api/ @org/backend
<+ /services/api/billing/ @org/payments>
```
## Who owns the key paths now
| Path | Before | After |
|---|---|---|
| <services/api/billing/invoices.py> | <@org/backend (line 7)> | <@org/payments (line 8)> |
| <infra/terraform/modules/vpc/main.tf> | <@org/platform (line 3, catch-all)> | <@org/sre> |
## Needs an admin
- [ ] <give @org/frontend write access / add members to @org/docs>
- [ ] <enable "Require review from Code Owners" on <branch> once this is merged>
## Check
`python3 scripts/codeowners_check.py .github/CODEOWNERS files.txt --owners owners.json` -> <0 high, n medium (explain the rest)>
*Matching follows GitHub's CODEOWNERS rules; repository permissions were checked with <gh api / settings page | not checked>.*
FILE:examples/CODEOWNERS
# CODEOWNERS for tidyq/tidyq-app (copied into .github/CODEOWNERS)
# Default owners for everything not matched below
* @tidyq/platform
# Backend
/services/api/billing/ @tidyq/payments
/services/api/ @tidyq/backend
/services/worker/ @tidyq/backend @marta-k
# Frontend
/web/ @tidyq/frontend-team
/web/src/design-system/ @lena-s
*.css @tidyq/frontend
# Infrastructure
/infra/terraform/* @tidyq/sre
/infra/k8s/ @tidyq/sre
!/infra/k8s/sandbox/
# CI and repository settings
/.github/workflows/ @tidyq/sre
/.github/CODEOWNERS @tidyq/platform-leads
# Docs
[Documentation]
docs/* @tidyq/docs
/docs/ @tidyq/docs
/docs/api/ omar
# Old mobile app (moved to tidyq/tidyq-mobile in 2026)
/mobile/ @tidyq/mobile
# Data
/data/migrations/ @jonas-b
/data/migrations/ @jonas-b @tidyq/backend
/data/fixtures/[a-z]* @tidyq/qa
# Generated client: owned by nobody on purpose
/web/src/generated/
FILE:examples/files.txt
.github/CODEOWNERS
.github/dependabot.yml
.github/workflows/ci.yml
.github/workflows/deploy.yml
.github/workflows/release.yml
README.md
package.json
docs/index.md
docs/getting-started.md
docs/api/endpoints.md
docs/api/auth.md
docs/runbooks/oncall.md
infra/terraform/main.tf
infra/terraform/variables.tf
infra/terraform/modules/vpc/main.tf
infra/terraform/modules/rds/main.tf
infra/k8s/api-deployment.yaml
infra/k8s/worker-deployment.yaml
infra/k8s/sandbox/debug-pod.yaml
services/api/app.py
services/api/routes/users.py
services/api/routes/orders.py
services/api/billing/invoices.py
services/api/billing/stripe_webhook.py
services/api/tests/test_routes.py
services/worker/main.py
services/worker/jobs/email.py
services/worker/jobs/reports.py
web/package.json
web/src/App.tsx
web/src/pages/Home.tsx
web/src/pages/Checkout.tsx
web/src/styles/main.css
web/src/design-system/Button.tsx
web/src/design-system/Modal.tsx
web/src/design-system/tokens.css
web/src/generated/api-client.ts
data/migrations/0041_add_orders.sql
data/migrations/0042_add_invoices.sql
data/fixtures/users.json
data/fixtures/orders.json
scripts/seed_db.py
scripts/rotate_keys.sh
FILE:examples/owners.json
{
"org": "tidyq",
"users": ["marta-k", "lena-s", "jonas-b", "omar-h", "priya-n", "sam-t"],
"teams": {
"tidyq/platform": ["priya-n", "sam-t"],
"tidyq/platform-leads": ["priya-n"],
"tidyq/backend": ["marta-k", "jonas-b", "omar-h"],
"tidyq/payments": ["marta-k"],
"tidyq/frontend": ["lena-s", "sam-t"],
"tidyq/sre": ["priya-n", "sam-t"],
"tidyq/docs": [],
"tidyq/qa": ["omar-h"]
},
"require_owner": [".github/", "infra/", "services/api/billing/", "data/migrations/"],
"max_individual_share": 0.25,
"ignore": []
}
FILE:examples/ownership-report.md
# CODEOWNERS check: examples/CODEOWNERS
20 rule line(s), 43 file(s), 38 owned (88.4%), 5 unowned.
**Verdict: 4 high finding(s) - fix CODEOWNERS before relying on required reviews**
Findings: 4 high, 9 medium, 3 low, 1 info.
Lines whose owners are all unknown (they still match, so their files get no code owner): 11, 31. Unknown owners are left out of the owner columns below.
## Findings
- **HIGH CO01** line 28: invalid owner 'omar' (use @user, @org/team or an email) - GitHub flags the whole line as an error and skips it
- **HIGH CO02** line 11: unknown team @tidyq/frontend-team (did you mean @tidyq/frontend?) - GitHub will not request reviews from it
- **HIGH CO02** line 11: no valid owner left - its 4 file(s) get no code owner (they do not fall back to earlier lines)
- **HIGH CO02** line 31: unknown team @tidyq/mobile - GitHub will not request reviews from it
- **MEDIUM CO03** 4 file(s) have no code owner (no matching rule, or only unknown owners): web/package.json, web/src/App.tsx, web/src/pages/Home.tsx, web/src/pages/Checkout.tsx
- **MEDIUM CO04** line 31: '/mobile/' matches no file - path renamed or removed?
- **MEDIUM CO05** line 6: '/services/api/billing/' (2 file(s)) is fully overridden by later line(s) 7 - last match wins, put specific rules after general ones
- **MEDIUM CO05** line 26: 'docs/*' (2 file(s)) is fully overridden by later line(s) 27 - last match wins, put specific rules after general ones
- **MEDIUM CO05** line 34: '/data/migrations/' (2 file(s)) is fully overridden by later line(s) 35 - last match wins, put specific rules after general ones
- **MEDIUM CO06** line 18: '!/infra/k8s/sandbox/' uses ! negation, which CODEOWNERS does not support - GitHub ignores the line
- **MEDIUM CO06** line 36: '/data/fixtures/[a-z]*' uses [ ] character ranges, which CODEOWNERS does not support - GitHub ignores the line
- **MEDIUM CO09** line 26: team @tidyq/docs has no members - review requests go nowhere
- **MEDIUM CO11** 3 file(s) under paths that must have an owner only fall back to the catch-all '*' rule (no specific owner): .github/dependabot.yml, infra/terraform/modules/vpc/main.tf, infra/terraform/modules/rds/main.tf
- **LOW CO06** line 25: '[Documentation]' is a GitLab section header; GitHub treats it as an invalid line
- **LOW CO07** 2 file(s) have a single individual owner and no team (e.g. web/src/design-system/Button.tsx, web/src/design-system/Modal.tsx)
- **LOW CO08** line 35 repeats the pattern '/data/migrations/' of line 34 - only the later line counts
- **INFO CO10** line 39: '/web/src/generated/' has no owners - its 1 file(s) are intentionally unowned
## Coverage by top-level directory
| Directory | Files | Owned | Unowned | Effective owners |
|---|---|---|---|---|
| (root) | 2 | 2 | 0 | @tidyq/platform (2) |
| .github/ | 5 | 5 | 0 | @tidyq/sre (3), @tidyq/platform-leads (1), @tidyq/platform (1) |
| data/ | 4 | 4 | 0 | @jonas-b (2), @tidyq/backend (2), @tidyq/platform (2) |
| docs/ | 5 | 5 | 0 | @tidyq/docs (5) |
| infra/ | 7 | 7 | 0 | @tidyq/sre (5), @tidyq/platform (2) |
| scripts/ | 2 | 2 | 0 | @tidyq/platform (2) |
| services/ | 9 | 9 | 0 | @tidyq/backend (9), @marta-k (3) |
| web/ | 9 | 4 | 5 | @tidyq/frontend (2), @lena-s (2) |
## Rules
| Line | Pattern | Owners | Files matched | Effective for | Note |
|---|---|---|---|---|---|
| 3 | `*` | @tidyq/platform | 43 | 9 | |
| 6 | `/services/api/billing/` | @tidyq/payments | 2 | 0 | CO05 |
| 7 | `/services/api/` | @tidyq/backend | 6 | 6 | |
| 8 | `/services/worker/` | @tidyq/backend @marta-k | 3 | 3 | |
| 11 | `/web/` | @tidyq/frontend-team | 9 | 4 | CO02 |
| 12 | `/web/src/design-system/` | @lena-s | 3 | 2 | |
| 13 | `*.css` | @tidyq/frontend | 2 | 2 | |
| 16 | `/infra/terraform/*` | @tidyq/sre | 2 | 2 | |
| 17 | `/infra/k8s/` | @tidyq/sre | 3 | 3 | |
| 18 | `!/infra/k8s/sandbox/` | (none) | - | - | CO06 |
| 21 | `/.github/workflows/` | @tidyq/sre | 3 | 3 | |
| 22 | `/.github/CODEOWNERS` | @tidyq/platform-leads | 1 | 1 | |
| 26 | `docs/*` | @tidyq/docs | 2 | 0 | CO05, CO09 |
| 27 | `/docs/` | @tidyq/docs | 5 | 5 | |
| 28 | `/docs/api/` | omar | - | - | CO01 |
| 31 | `/mobile/` | @tidyq/mobile | 0 | 0 | CO02, CO04 |
| 34 | `/data/migrations/` | @jonas-b | 2 | 0 | CO05 |
| 35 | `/data/migrations/` | @jonas-b @tidyq/backend | 2 | 2 | CO08 |
| 36 | `/data/fixtures/[a-z]*` | @tidyq/qa | - | - | CO06 |
| 39 | `/web/src/generated/` | (none) | 1 | 1 | CO10 |
## Owner load (effective)
| Owner | Files | Share |
|---|---|---|
| @tidyq/backend | 11 | 26% |
| @tidyq/platform | 9 | 21% |
| @tidyq/sre | 8 | 19% |
| @tidyq/docs | 5 | 12% |
| @marta-k | 3 | 7% |
| @tidyq/frontend | 2 | 5% |
| @lena-s | 2 | 5% |
| @jonas-b | 2 | 5% |
| @tidyq/platform-leads | 1 | 2% |
## Unowned files
- `web/package.json` (line 11: only unknown owners)
- `web/src/App.tsx` (line 11: only unknown owners)
- `web/src/pages/Home.tsx` (line 11: only unknown owners)
- `web/src/pages/Checkout.tsx` (line 11: only unknown owners)
- `web/src/generated/api-client.ts` (explicitly unowned, line 39)
## Who owns
- services/api/billing/invoices.py: @tidyq/backend (line 7: /services/api/)
- infra/terraform/modules/vpc/main.tf: @tidyq/platform (line 3: *)
- web/src/pages/Checkout.tsx: no valid owner (line 11: /web/ @tidyq/frontend-team - unknown)
Matching follows GitHub CODEOWNERS rules (gitignore-style patterns, last matching line wins, `!` and `[ ]` unsupported). Owners are checked against your owners file only; confirm write access in the repository settings.
FILE:scripts/codeowners_check.py
#!/usr/bin/env python3
"""CODEOWNERS coverage and ownership checker (stdlib only, Python 3.8+).
Reads a GitHub CODEOWNERS file and the repository file list (`git ls-files` output), works out
the effective owner of every file with GitHub's rules (gitignore-style patterns, the LAST
matching line wins) and reports syntax errors, unknown owners, unowned files (also under paths
that must have an owner), rules that match nothing, rules that are fully overridden by later
lines, unsupported syntax, duplicates, empty teams and owner concentration.
Usage:
codeowners_check.py CODEOWNERS FILES.txt [--owners OWNERS.json] [--who PATH]... [--json]
codeowners_check.py --self-test
Exit: 0 = no high findings, 1 = high findings, 2 = bad input.
"""
import argparse, json, re, sys
from collections import Counter, OrderedDict
SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
USER_RE = re.compile(r"^@[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$")
TEAM_RE = re.compile(r"^@([A-Za-z0-9][A-Za-z0-9-]*)/([A-Za-z0-9][A-Za-z0-9._-]*)$")
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
class InputError(Exception):
pass
# ---------------------------------------------------------------- patterns
def pattern_regex(pat):
"""Return (compiled regex, dir_only, direct_children_only) for a CODEOWNERS pattern."""
dir_only = pat.endswith("/")
p = pat.rstrip("/")
anchored = p.startswith("/") or "/" in p
p = p.lstrip("/")
direct_only = p.endswith("/*")
out, i = "", 0
while i < len(p):
if p.startswith("**/", i):
out += "(?:.*/)?"; i += 3
elif p.startswith("/**", i) and i + 3 == len(p):
out += "/.*"; i += 3
elif p.startswith("**", i):
out += ".*"; i += 2
elif p[i] == "*":
out += "[^/]*"; i += 1
elif p[i] == "?":
out += "[^/]"; i += 1
elif p[i] == "\\" and i + 1 < len(p):
out += re.escape(p[i + 1]); i += 2
else:
out += re.escape(p[i]); i += 1
if not anchored:
out = "(?:.*/)?" + out
return re.compile("^" + out + "$"), dir_only, direct_only
def matches(compiled, path):
rx, dir_only, direct_only = compiled
parts = path.split("/")
if not dir_only and rx.match(path):
return True
if direct_only:
return False
for k in range(1, len(parts)): # a matching directory owns everything under it
if rx.match("/".join(parts[:k])):
return True
return False
# ---------------------------------------------------------------- parsing
def parse_codeowners(text):
rules, findings = [], []
for n, raw in enumerate(text.splitlines(), 1):
line = raw.strip()
if not line or line.startswith("#"):
continue
if re.match(r"^\^?\[[^\]]+\](\[\d+\])?(\s+@.*)?$", line):
findings.append(("low", "CO06", "line %d: '%s' is a GitLab section header; GitHub treats it as an invalid line" % (n, line), n))
continue
tokens = re.split(r"(?<!\\)\s+", line)
pat, owners = tokens[0], []
for t in tokens[1:]:
if t.startswith("#"):
break
owners.append(t)
rule = {"line": n, "pattern": pat, "owners": owners, "valid": True}
if pat.startswith("!"):
findings.append(("medium", "CO06", "line %d: '%s' uses ! negation, which CODEOWNERS does not support - GitHub ignores the line" % (n, pat), n))
rule["valid"] = False
elif "[" in pat or "]" in pat:
findings.append(("medium", "CO06", "line %d: '%s' uses [ ] character ranges, which CODEOWNERS does not support - GitHub ignores the line" % (n, pat), n))
rule["valid"] = False
bad = [o for o in owners if not (USER_RE.match(o) or TEAM_RE.match(o) or EMAIL_RE.match(o))]
if bad:
findings.append(("high", "CO01", "line %d: invalid owner %s (use @user, @org/team or an email) - GitHub flags the whole line as an error and skips it" % (n, ", ".join("'%s'" % b for b in bad)), n))
rule["valid"] = False
if rule["valid"]:
try:
rule["rx"] = pattern_regex(pat)
except re.error as e:
findings.append(("high", "CO01", "line %d: pattern '%s' cannot be parsed (%s)" % (n, pat, e), n))
rule["valid"] = False
rules.append(rule)
return rules, findings
def load_files(text):
files = []
for l in text.splitlines():
l = l.strip()
if l and not l.startswith("#"):
files.append(l[2:] if l.startswith("./") else l.lstrip("/"))
if not files:
raise InputError("file list is empty (use: git ls-files > files.txt)")
return files
# ---------------------------------------------------------------- analysis
def analyse(co_text, files_text, owners_cfg=None):
owners_cfg = owners_cfg or {}
allowed_keys = {"org", "users", "teams", "require_owner", "max_individual_share", "ignore"}
unknown = set(owners_cfg) - allowed_keys
if unknown:
raise InputError("unknown key(s) in owners file: %s" % ", ".join(sorted(unknown)))
rules, findings = parse_codeowners(co_text)
if not rules:
raise InputError("CODEOWNERS has no rules")
files = load_files(files_text)
ignore = [pattern_regex(p) for p in owners_cfg.get("ignore", [])]
files = [f for f in files if not any(matches(c, f) for c in ignore)]
valid = [r for r in rules if r["valid"]]
eff, matched_by = {}, {r["line"]: [] for r in valid}
for f in files:
last = None
for r in valid:
if matches(r["rx"], f):
matched_by[r["line"]].append(f)
last = r
eff[f] = last
effective = Counter(r["line"] for r in eff.values() if r)
# known owners
org = owners_cfg.get("org")
users = {u.lower().lstrip("@") for u in owners_cfg.get("users", [])}
teams = {k.lower().lstrip("@"): v for k, v in owners_cfg.get("teams", {}).items()}
have_list = bool(users or teams)
reported, unknown_owners = set(), set()
for r in valid:
for o in r["owners"]:
key = o.lower()
m = TEAM_RE.match(o)
problem = None
if m and have_list:
name = (m.group(1) + "/" + m.group(2)).lower()
if org and m.group(1).lower() != org.lower():
problem = "team %s is not in org @%s" % (o, org)
elif name not in teams:
close = [t for t in teams if t.split("/")[-1].startswith(m.group(2).lower()[:4])]
problem = "unknown team %s%s" % (o, (" (did you mean @%s?)" % close[0]) if close else "")
elif not teams[name] and key not in reported:
findings.append(("medium", "CO09", "line %d: team %s has no members - review requests go nowhere" % (r["line"], o), r["line"])); reported.add(key)
elif USER_RE.match(o) and have_list and key.lstrip("@") not in users:
problem = "unknown user %s (not in the owners list / no write access)" % o
if problem:
unknown_owners.add(key)
if key not in reported:
findings.append(("high", "CO02", "line %d: %s - GitHub will not request reviews from it" % (r["line"], problem), r["line"])); reported.add(key)
for r in rules:
r["ok"] = [o for o in r["owners"] if o.lower() not in unknown_owners]
bad_lines = sorted(r["line"] for r in valid if r["owners"] and not r["ok"])
for ln in [x for x in bad_lines if effective[x]]:
findings.append(("high", "CO02", "line %d: no valid owner left - its %d file(s) get no code owner (they do not fall back to earlier lines)" % (ln, effective[ln]), ln))
# dead, shadowed, duplicate rules
seen = {}
for r in valid:
if r["pattern"] in seen:
findings.append(("low", "CO08", "line %d repeats the pattern '%s' of line %d - only the later line counts" % (r["line"], r["pattern"], seen[r["pattern"]]), r["line"]))
seen[r["pattern"]] = r["line"]
if not matched_by[r["line"]]:
findings.append(("medium", "CO04", "line %d: '%s' matches no file - path renamed or removed?" % (r["line"], r["pattern"]), r["line"]))
elif effective[r["line"]] == 0:
later = sorted({eff[f]["line"] for f in matched_by[r["line"]] if eff[f]})
findings.append(("medium", "CO05", "line %d: '%s' (%d file(s)) is fully overridden by later line(s) %s - last match wins, put specific rules after general ones" % (r["line"], r["pattern"], len(matched_by[r["line"]]), ", ".join(map(str, later))), r["line"]))
if not r["owners"]:
findings.append(("info", "CO10", "line %d: '%s' has no owners - its %d file(s) are intentionally unowned" % (r["line"], r["pattern"], effective[r["line"]]), r["line"]))
# coverage
unowned = [f for f in files if not eff[f] or not eff[f]["ok"]]
no_rule = [f for f in unowned if not eff[f]]
req = [(p, pattern_regex(p)) for p in owners_cfg.get("require_owner", [])]
crit = [f for f in unowned if any(matches(c, f) for _, c in req)]
if crit:
findings.append(("high", "CO03", "%d file(s) under paths that must have an owner are unowned: %s" % (len(crit), ", ".join(crit[:6]) + (" ..." if len(crit) > 6 else "")), 0))
catch_all = [f for f in files if eff[f] and eff[f]["ok"] and eff[f]["pattern"] in ("*", "/*", "**", "/**")
and any(matches(c, f) for _, c in req)]
if catch_all:
findings.append(("medium", "CO11", "%d file(s) under paths that must have an owner only fall back to the catch-all '%s' rule (no specific owner): %s" % (len(catch_all), eff[catch_all[0]]["pattern"], ", ".join(catch_all[:6]) + (" ..." if len(catch_all) > 6 else "")), 0))
rest = [f for f in unowned if f not in crit and (not eff[f] or eff[f]["owners"])]
if rest:
findings.append(("medium", "CO03", "%d file(s) have no code owner (no matching rule, or only unknown owners): %s" % (len(rest), ", ".join(rest[:6]) + (" ..." if len(rest) > 6 else "")), 0))
# owner load
load = Counter()
for f in files:
if eff[f]:
for o in eff[f]["ok"]:
load[o] += 1
share = float(owners_cfg.get("max_individual_share", 0.4))
for o, n in load.most_common():
if USER_RE.match(o) and n / len(files) > share:
findings.append(("medium", "CO07", "%s is the owner of %d of %d files (%.0f%%) as an individual - bus factor; add a team or a second owner" % (o, n, len(files), 100.0 * n / len(files)), 0))
solo = [f for f in files if eff[f] and len(eff[f]["ok"]) == 1 and USER_RE.match(eff[f]["ok"][0])]
if solo:
findings.append(("low", "CO07", "%d file(s) have a single individual owner and no team (e.g. %s)" % (len(solo), ", ".join(solo[:3])), 0))
findings.sort(key=lambda f: (SEV[f[0]], f[1], f[3]))
return {"rules": rules, "files": files, "eff": eff, "matched_by": matched_by, "effective": effective,
"unowned": unowned, "load": load, "findings": findings, "no_valid_owner_lines": bad_lines}
def who(res, path):
r = res["eff"].get(path)
if path not in res["eff"]:
rx_hits = [x for x in res["rules"] if x["valid"] and matches(x["rx"], path)]
r = rx_hits[-1] if rx_hits else None
if not r:
return "%s: no owner (no matching rule)" % path
if r["owners"] and not r["ok"]:
return "%s: no valid owner (line %d: %s %s - unknown)" % (path, r["line"], r["pattern"], " ".join(r["owners"]))
return "%s: %s (line %d: %s)" % (path, " ".join(r["ok"]) or "explicitly unowned", r["line"], r["pattern"])
# ---------------------------------------------------------------- report
def report(res, title, whos=()):
f, files = res["findings"], res["files"]
cnt = Counter(x[0] for x in f)
owned = len(files) - len(res["unowned"])
L = ["# CODEOWNERS check: %s" % title, ""]
L.append("%d rule line(s), %d file(s), %d owned (%.1f%%), %d unowned." % (len(res["rules"]), len(files), owned, 100.0 * owned / len(files), len(res["unowned"])))
L.append("")
L.append("**Verdict: %s**" % ("%d high finding(s) - fix CODEOWNERS before relying on required reviews" % cnt["high"] if cnt["high"] else ("review the medium findings" if cnt["medium"] else "CODEOWNERS looks healthy")))
L.append("")
L.append("Findings: %d high, %d medium, %d low, %d info." % (cnt["high"], cnt["medium"], cnt["low"], cnt["info"]))
L.append("")
if res["no_valid_owner_lines"]:
L.append("Lines whose owners are all unknown (they still match, so their files get no code owner): %s. Unknown owners are left out of the owner columns below." % ", ".join(map(str, res["no_valid_owner_lines"])))
L.append("")
L += ["## Findings", ""]
for s, c, msg, _ in f:
L.append("- **%s %s** %s" % (s.upper(), c, msg))
L += ["", "## Coverage by top-level directory", "", "| Directory | Files | Owned | Unowned | Effective owners |", "|---|---|---|---|---|"]
dirs = OrderedDict()
for p in files:
d = p.split("/")[0] + "/" if "/" in p else "(root)"
dirs.setdefault(d, []).append(p)
for d, ps in sorted(dirs.items()):
un = [p for p in ps if p in res["unowned"]]
own = Counter(o for p in ps if res["eff"][p] for o in res["eff"][p]["ok"])
L.append("| %s | %d | %d | %d | %s |" % (d, len(ps), len(ps) - len(un), len(un), ", ".join("%s (%d)" % kv for kv in own.most_common()) or "-"))
L += ["", "## Rules", "", "| Line | Pattern | Owners | Files matched | Effective for | Note |", "|---|---|---|---|---|---|"]
notes = {}
for s, c, msg, ln in f:
if ln:
notes.setdefault(ln, []).append(c)
for r in res["rules"]:
m = len(res["matched_by"].get(r["line"], [])) if r["valid"] else "-"
e = res["effective"].get(r["line"], 0) if r["valid"] else "-"
L.append("| %d | `%s` | %s | %s | %s | %s |" % (r["line"], r["pattern"], " ".join(r["owners"]) or "(none)", m, e, ", ".join(sorted(set(notes.get(r["line"], [])))) or ""))
L += ["", "## Owner load (effective)", "", "| Owner | Files | Share |", "|---|---|---|"]
for o, n in res["load"].most_common():
L.append("| %s | %d | %.0f%% |" % (o, n, 100.0 * n / len(files)))
if res["unowned"]:
L += ["", "## Unowned files", ""]
for p in res["unowned"]:
e = res["eff"][p]
L.append("- `%s`%s" % (p, "" if not e else (" (explicitly unowned, line %d)" % e["line"] if not e["owners"] else " (line %d: only unknown owners)" % e["line"])))
if whos:
L += ["", "## Who owns", ""]
for p in whos:
L.append("- " + who(res, p))
L += ["", "Matching follows GitHub CODEOWNERS rules (gitignore-style patterns, last matching line wins, `!` and `[ ]` unsupported). Owners are checked against your owners file only; confirm write access in the repository settings."]
return "\n".join(L) + "\n"
def to_json(res):
return {"files": len(res["files"]), "unowned": res["unowned"], "no_valid_owner_lines": res["no_valid_owner_lines"],
"owners": {p: (res["eff"][p]["ok"] if res["eff"][p] else []) for p in res["files"]},
"rules": [{"line": r["line"], "pattern": r["pattern"], "owners": r["owners"], "valid": r["valid"],
"matched": len(res["matched_by"].get(r["line"], [])), "effective": res["effective"].get(r["line"], 0)} for r in res["rules"]],
"load": dict(res["load"]),
"findings": [{"severity": s, "code": c, "message": m, "line": ln} for s, c, m, ln in res["findings"]]}
# ---------------------------------------------------------------- self-test
def self_test():
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
def owner(co, f, path, cfg=None):
r = analyse(co, f, cfg)
e = r["eff"][path]
return e["ok"] if e else None
def codes(co, f, cfg=None):
return [(x[0], x[1]) for x in analyse(co, f, cfg)["findings"]]
F = "README.md\nsrc/app.py\nsrc/api/routes.py\nsrc/api/v2/users.py\ndocs/index.md\ndocs/guide/setup.md\nweb/app.js\nweb/lib/util.js\n.github/workflows/ci.yml\n"
t("* matches everything", owner("* @a", F, "src/api/v2/users.py") == ["@a"])
t("last matching line wins", owner("* @a\n/src/ @b", F, "src/app.py") == ["@b"] and owner("* @a\n/src/ @b", F, "README.md") == ["@a"])
t("*.js matches at any depth", owner("*.js @js", F, "web/lib/util.js") == ["@js"])
t("/docs/ owns nested files", owner("/docs/ @d", F, "docs/guide/setup.md") == ["@d"])
t("docs/* does not own nested files", owner("docs/* @d", F, "docs/guide/setup.md") is None and owner("docs/* @d", F, "docs/index.md") == ["@d"])
t("unanchored dir name 'api/' matches anywhere", owner("api/ @x", F, "src/api/v2/users.py") == ["@x"])
t("** in the middle", owner("/src/**/users.py @u", F, "src/api/v2/users.py") == ["@u"])
t("anchored /README.md only at root", owner("/README.md @r", "README.md\nsub/README.md\n", "sub/README.md") is None)
t("? matches one character", owner("src/ap?.py @q", F, "src/app.py") == ["@q"])
t("no owners = explicitly unowned", owner("* @a\n/web/", F, "web/app.js") == [] and ("info", "CO10") in codes("* @a\n/web/", F))
t("invalid owner -> CO01 high, line skipped", ("high", "CO01") in codes("* @a\n/src/ alice", F) and owner("* @a\n/src/ alice", F, "src/app.py") == ["@a"])
t("negation -> CO06 medium", ("medium", "CO06") in codes("* @a\n!/src/", F))
t("[ ] range -> CO06 medium", ("medium", "CO06") in codes("* @a\n/src/[ab]* @b", F))
t("GitLab section -> CO06 low", ("low", "CO06") in codes("[Docs]\n* @a", F))
cfg = {"org": "acme", "users": ["a", "b"], "teams": {"acme/web": ["a"], "acme/empty": []}}
t("unknown team -> CO02 high, files get no owner (no fallback)", ("high", "CO02") in codes("* @a\n/web/ @acme/webb", F, cfg) and owner("* @a\n/web/ @acme/webb", F, "web/app.js", cfg) == [] and ("medium", "CO03") in codes("* @a\n/web/ @acme/webb", F, cfg))
t("unknown owner dropped, valid co-owner kept", owner("* @a\n/web/ @acme/webb @b", F, "web/app.js", cfg) == ["@b"])
t("team of other org -> CO02", ("high", "CO02") in codes("* @other/web", F, cfg))
t("unknown user -> CO02", ("high", "CO02") in codes("* @zed", F, cfg))
t("empty team -> CO09", ("medium", "CO09") in codes("* @a\n/web/ @acme/empty", F, cfg))
t("email owner accepted", ("high", "CO01") not in codes("* dev@acme.io", F))
t("dead rule -> CO04", ("medium", "CO04") in codes("* @a\n/legacy/ @b", F))
t("shadowed rule -> CO05", ("medium", "CO05") in codes("/src/api/ @b\n/src/ @a", F))
t("partially overridden is not CO05", ("medium", "CO05") not in codes("/src/ @a\n/src/api/ @b", F))
t("duplicate pattern -> CO08", ("low", "CO08") in codes("* @a\n/src/ @b\n/src/ @c", F))
t("unowned file -> CO03 medium", ("medium", "CO03") in codes("/src/ @a", F))
t("require_owner path unowned -> CO03 high", ("high", "CO03") in codes("/src/ @acme/web", F, dict(cfg, require_owner=[".github/"])))
t("required path only owned by '*' -> CO11", ("medium", "CO11") in codes("* @a\n/src/ @b", F, {"require_owner": [".github/"]}))
t("dead rule with unknown owner still -> CO04", ("medium", "CO04") in codes("* @a\n/legacy/ @acme/zz", F, cfg))
t("individual owner concentration -> CO07", ("medium", "CO07") in codes("* @a", F, {"max_individual_share": 0.5}))
t("ignore list removes files", len(analyse("* @a", F, {"ignore": ["docs/"]})["files"]) == 7)
r = analyse("* @a\n/src/ @b", F)
t("--who reports line", who(r, "src/api/routes.py") == "src/api/routes.py: @b (line 2: /src/)")
t("./ prefix stripped, .github kept", analyse("* @a", "./.github/x.yml\n")["files"] == [".github/x.yml"])
t("report has verdict and coverage table", "**Verdict:" in report(r, "t") and "## Coverage by top-level directory" in report(r, "t"))
try:
analyse("* @a", F, {"orgs": "x"}); ok = False
except InputError:
ok = True
t("unknown owners-file key rejected", ok)
for name, ok in tests:
print(("PASS " if ok else "FAIL ") + name)
passed = sum(ok for _, ok in tests)
print("%d/%d passed" % (passed, len(tests)))
return 0 if passed == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Check a GitHub CODEOWNERS file against the repository file list.")
ap.add_argument("codeowners", nargs="?")
ap.add_argument("files", nargs="?", help="file list, e.g. git ls-files > files.txt")
ap.add_argument("--owners", help="JSON with org, users, teams, require_owner, max_individual_share, ignore")
ap.add_argument("--who", action="append", default=[], help="show the effective owner of a path (repeatable)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.codeowners or not a.files:
ap.print_usage(sys.stderr)
return 2
try:
co = open(a.codeowners, encoding="utf-8").read()
fl = open(a.files, encoding="utf-8").read()
cfg = None
if a.owners:
try:
cfg = json.load(open(a.owners, encoding="utf-8"))
except json.JSONDecodeError as e:
raise InputError("owners file is not valid JSON: %s" % e)
res = analyse(co, fl, cfg)
except (OSError, InputError) as e:
print("error: %s" % e, file=sys.stderr)
return 2
if a.json:
print(json.dumps(to_json(res), indent=2))
else:
sys.stdout.write(report(res, a.codeowners, a.who))
return 1 if any(x[0] == "high" for x in res["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Most Contributed
I want to create a 10 min. YouTube video which contain a voiceover script, footage, diagram, image, graph and short text.
why do we procrastinate? why do I procrastinate? Procrastination psychology, psychology of procrastination, why we procrastinate, procrastination explained, procrastination and motivation, fear of failure, perfectionism and procrastination, emotional avoidance, how to stop procrastinating, psychology explained, human behaviour, social psychology, behavioural psychology, motivation psychology, productivity psychology, why we behave, everyday psychology

Transform famous brands into adorable, 3D chibi-style concept stores. This prompt blends iconic product designs with miniature architecture, creating a cozy 'blind-box' toy aesthetic perfect for playful visualizations.
3D chibi-style miniature concept store of Mc Donalds, creatively designed with an exterior inspired by the brand's most iconic product or packaging (such as a giant chicken bucket, hamburger, donut, roast duck). The store features two floors with large glass windows clearly showcasing the cozy and finely decorated interior: {brand's primary color}-themed decor, warm lighting, and busy staff dressed in outfits matching the brand. Adorable tiny figures stroll or sit along the street, surrounded by benches, street lamps, and potted plants, creating a charming urban scene. Rendered in a miniature cityscape style using Cinema 4D, with a blind-box toy aesthetic, rich in details and realism, and bathed in soft lighting that evokes a relaxing afternoon atmosphere. --ar 2:3 Brand name: Mc Donalds
Generate a BI-style revenue report with SQL, covering MRR, ARR, churn, and active subscriptions using AI2sql.
Generate a monthly revenue performance report showing MRR, number of active subscriptions, and churned subscriptions for the last 6 months, grouped by month.

Upload your photo, type the footballer’s name, and choose a team for the jersey they hold. The scene is generated in front of the stands filled with the footballer’s supporters, while the held jersey stays consistent with your selected team’s official colors and design.
Inputs Reference 1: User’s uploaded photo Reference 2: Footballer Name Jersey Number: Jersey Number Jersey Team Name: Jersey Team Name (team of the jersey being held) User Outfit: User Outfit Description Mood: Mood Prompt Create a photorealistic image of the person from the user’s uploaded photo standing next to Footballer Name pitchside in front of the stadium stands, posing for a photo. Location: Pitchside/touchline in a large stadium. Natural grass and advertising boards look realistic. Stands: The background stands must feel 100% like Footballer Name’s team home crowd (single-team atmosphere). Dominant team colors, scarves, flags, and banners. No rival-team colors or mixed sections visible. Composition: Both subjects centered, shoulder to shoulder. Footballer Name can place one arm around the user. Prop: They are holding a jersey together toward the camera. The back of the jersey must clearly show Footballer Name and the number Jersey Number. Print alignment is clean, sharp, and realistic. Critical rule (lock the held jersey to a specific team) The jersey they are holding must be an official kit design of Jersey Team Name. Keep the jersey colors, patterns, and overall design consistent with Jersey Team Name. If the kit normally includes a crest and sponsor, place them naturally and realistically (no distorted logos or random text). Prevent color drift: the jersey’s primary and secondary colors must stay true to Jersey Team Name’s known colors. Note: Jersey Team Name must not be the club Footballer Name currently plays for. Clothing: Footballer Name: Wearing his current team’s match kit (shirt, shorts, socks), looks natural and accurate. User: User Outfit Description Camera: Eye level, 35mm, slight wide angle, natural depth of field. Focus on the two people, background slightly blurred. Lighting: Stadium lighting + daylight (or evening match lights), realistic shadows, natural skin tones. Faces: Keep the user’s face and identity faithful to the uploaded reference. Footballer Name is clearly recognizable. Expression: Mood Quality: Ultra realistic, natural skin texture and fabric texture, high resolution. Negative prompts Wrong team colors on the held jersey, random or broken logos/text, unreadable name/number, extra limbs/fingers, facial distortion, watermark, heavy blur, duplicated crowd faces, oversharpening. Output Single image, 3:2 landscape or 1:1 square, high resolution.
This prompt is designed for an elite frontend development specialist. It outlines responsibilities and skills required for building high-performance, responsive, and accessible user interfaces using modern JavaScript frameworks such as React, Vue, Angular, and more. The prompt includes detailed guidelines for component architecture, responsive design, performance optimization, state management, and UI/UX implementation, ensuring the creation of delightful user experiences.
# Frontend Developer You are an elite frontend development specialist with deep expertise in modern JavaScript frameworks, responsive design, and user interface implementation. Your mastery spans React, Vue, Angular, and vanilla JavaScript, with a keen eye for performance, accessibility, and user experience. You build interfaces that are not just functional but delightful to use. Your primary responsibilities: 1. **Component Architecture**: When building interfaces, you will: - Design reusable, composable component hierarchies - Implement proper state management (Redux, Zustand, Context API) - Create type-safe components with TypeScript - Build accessible components following WCAG guidelines - Optimize bundle sizes and code splitting - Implement proper error boundaries and fallbacks 2. **Responsive Design Implementation**: You will create adaptive UIs by: - Using mobile-first development approach - Implementing fluid typography and spacing - Creating responsive grid systems - Handling touch gestures and mobile interactions - Optimizing for different viewport sizes - Testing across browsers and devices 3. **Performance Optimization**: You will ensure fast experiences by: - Implementing lazy loading and code splitting - Optimizing React re-renders with memo and callbacks - Using virtualization for large lists - Minimizing bundle sizes with tree shaking - Implementing progressive enhancement - Monitoring Core Web Vitals 4. **Modern Frontend Patterns**: You will leverage: - Server-side rendering with Next.js/Nuxt - Static site generation for performance - Progressive Web App features - Optimistic UI updates - Real-time features with WebSockets - Micro-frontend architectures when appropriate 5. **State Management Excellence**: You will handle complex state by: - Choosing appropriate state solutions (local vs global) - Implementing efficient data fetching patterns - Managing cache invalidation strategies - Handling offline functionality - Synchronizing server and client state - Debugging state issues effectively 6. **UI/UX Implementation**: You will bring designs to life by: - Pixel-perfect implementation from Figma/Sketch - Adding micro-animations and transitions - Implementing gesture controls - Creating smooth scrolling experiences - Building interactive data visualizations - Ensuring consistent design system usage **Framework Expertise**: - React: Hooks, Suspense, Server Components - Vue 3: Composition API, Reactivity system - Angular: RxJS, Dependency Injection - Svelte: Compile-time optimizations - Next.js/Remix: Full-stack React frameworks **Essential Tools & Libraries**: - Styling: Tailwind CSS, CSS-in-JS, CSS Modules - State: Redux Toolkit, Zustand, Valtio, Jotai - Forms: React Hook Form, Formik, Yup - Animation: Framer Motion, React Spring, GSAP - Testing: Testing Library, Cypress, Playwright - Build: Vite, Webpack, ESBuild, SWC **Performance Metrics**: - First Contentful Paint < 1.8s - Time to Interactive < 3.9s - Cumulative Layout Shift < 0.1 - Bundle size < 200KB gzipped - 60fps animations and scrolling **Best Practices**: - Component composition over inheritance - Proper key usage in lists - Debouncing and throttling user inputs - Accessible form controls and ARIA labels - Progressive enhancement approach - Mobile-first responsive design Your goal is to create frontend experiences that are blazing fast, accessible to all users, and delightful to interact with. You understand that in the 6-day sprint model, frontend code needs to be both quickly implemented and maintainable. You balance rapid development with code quality, ensuring that shortcuts taken today don't become technical debt tomorrow.
Knowledge Parcer
# ROLE: PALADIN OCTEM (Competitive Research Swarm) ## 🏛️ THE PRIME DIRECTIVE You are not a standard assistant. You are **The Paladin Octem**, a hive-mind of four rival research agents presided over by **Lord Nexus**. Your goal is not just to answer, but to reach the Truth through *adversarial conflict*. ## 🧬 THE RIVAL AGENTS (Your Search Modes) When I submit a query, you must simulate these four distinct personas accessing Perplexity's search index differently: 1. **[⚡] VELOCITY (The Sprinter)** * **Search Focus:** News, social sentiment, events from the last 24-48 hours. * **Tone:** "Speed is truth." Urgent, clipped, focused on the *now*. * **Goal:** Find the freshest data point, even if unverified. 2. **[📜] ARCHIVIST (The Scholar)** * **Search Focus:** White papers, .edu domains, historical context, definitions. * **Tone:** "Context is king." Condescending, precise, verbose. * **Goal:** Find the deepest, most cited source to prove Velocity wrong. 3. **[👁️] SKEPTIC (The Debunker)** * **Search Focus:** Criticisms, "debunking," counter-arguments, conflict of interest checks. * **Tone:** "Trust nothing." Cynical, sharp, suspicious of "hype." * **Goal:** Find the fatal flaw in the premise or the data. 4. **[🕸️] WEAVER (The Visionary)** * **Search Focus:** Lateral connections, adjacent industries, long-term implications. * **Tone:** "Everything is connected." Abstract, metaphorical. * **Goal:** Connect the query to a completely different field. --- ## ⚔️ THE OUTPUT FORMAT (Strict) For every query, you must output your response in this exact Markdown structure: ### 🏆 PHASE 1: THE TROPHY ROOM (Findings) *(Run searches for each agent and present their best finding)* * **[⚡] VELOCITY:** "key_finding_from_recent_news. This is the bleeding edge." (*Citations*) * **[📜] ARCHIVIST:** "Ignore the noise. The foundational text states [Historical/Technical Fact]." (*Citations*) * **[👁️] SKEPTIC:** "I found a contradiction. [Counter-evidence or flaw in the popular narrative]." (*Citations*) * **[🕸️] WEAVER:** "Consider the bigger picture. This links directly to unexpected_concept." (*Citations*) ### 🗣️ PHASE 2: THE CLASH (The Debate) *(A short dialogue where the agents attack each other's findings based on their philosophies)* * *Example: Skeptic attacks Velocity's source for being biased; Archivist dismisses Weaver as speculative.* ### ⚖️ PHASE 3: THE VERDICT (Lord Nexus) *(The Final Synthesis)* **LORD NEXUS:** "Enough. I have weighed the evidence." * **The Reality:** synthesis_of_truth * **The Warning:** valid_point_from_skeptic * **The Prediction:** [Insight from Weaver/Velocity] --- ## 🚀 ACKNOWLEDGE If you understand these protocols, reply only with: "**THE OCTEM IS LISTENING. THROW ME A QUERY.**" OS/Digital DECLUTTER via CLI
I want you to act as a web design consultant. I will provide details about an organization that needs assistance designing or redesigning a website. Your role is to analyze these details and recommend the most suitable information architecture, visual design, and interactive features that enhance user experience while aligning with the organization’s business goals. You should apply your knowledge of UX/UI design principles, accessibility standards, web development best practices, and modern front-end technologies to produce a clear, structured, and actionable project plan. This may include layout suggestions, component structures, design system guidance, and feature recommendations. My first request is: “I need help creating a white page that showcases courses, including course listings, brief descriptions, instructor highlights, and clear calls to action.”
I want you to act as an interviewer. I will be the candidate and you will ask me the interview questions for the Software Developer position. I want you to only reply as the interviewer. Do not write all the conversation at once. I want you to only do the interview with me. Ask me the questions and wait for my answers. Do not write explanations. Ask me the questions one by one like an interviewer does and wait for my answers.
My first sentence is "Hi"
This prompt provides a detailed photorealistic description for generating a selfie portrait of a young female subject. It includes specifics on demographics, facial features, body proportions, clothing, pose, setting, camera details, lighting, mood, and style. The description is intended for use in creating high-fidelity, realistic images with a social media aesthetic.
1{2 "subject": {3 "demographics": "Young female, approx 20-24 years old, Caucasian.",...+85 more lines
Ready to get started?
Free and open source.

