# Nino Kroesen > Personal site and blog of Nino Kroesen: writing about AI, tooling, and building software, plus a lab of small tools built to answer one question each. Dutch on ninokroesen.nl, English on ninokroesen.com. This file is a machine index. Every page above is also served as markdown: append `.md` to the path (the home page is `/index.md`), or send `Accept: text/markdown`. The `.md` URLs are the cacheable ones. Labels here are English; post titles are in the host's locale (en). ## Pages - [Home](https://ninokroesen.com/): markdown at https://ninokroesen.com/index.md - [Blog index (all posts)](https://ninokroesen.com/blog): markdown at https://ninokroesen.com/blog.md - [Library — longer SEO guides](https://ninokroesen.com/library): markdown at https://ninokroesen.com/library.md - [Takes — short news commentary](https://ninokroesen.com/takes): markdown at https://ninokroesen.com/takes.md - [Projects](https://ninokroesen.com/projects): markdown at https://ninokroesen.com/projects.md - [About](https://ninokroesen.com/about): markdown at https://ninokroesen.com/about.md - [Lab — index of the experiments](https://ninokroesen.com/lab): markdown at https://ninokroesen.com/lab.md - [Hourly — a project](https://ninokroesen.com/hourly): markdown at https://ninokroesen.com/hourly.md - [Bitvested — a project](https://ninokroesen.com/bitvested): markdown at https://ninokroesen.com/bitvested.md - [ta.js — a project](https://ninokroesen.com/ta): markdown at https://ninokroesen.com/ta.md ## Laboratory - [Text as PNG](https://ninokroesen.com/render-text-as-png): Renders markdown or plain text into one dense PNG using a bundled 5x8 bitmap font. Built for vision models: the same text usually costs fewer input tokens as an image than as text, and the response reports both estimates. POST /api/render-text-as-png, GET /api/render-text-as-png. MCP tool `render_text_as_png`. Full machine description: https://ninokroesen.com/lab/api.json - [unslopf](https://ninokroesen.com/unslopf): Mechanical n-gram analysis (no LLM) over markdown you pass in, page URLs, or a sitemap. Returns the phrases that recur in that corpus but are not distinctive to it — a negative-keyword list to push a classifier away from filler — with document-frequency counts and a ready-to-paste prompt block. It compares documents against each other, so the corpus needs at least two: a single text has nothing to recur against and comes back empty. POST /api/unslopf, GET /api/unslopf. MCP tool `unslopf_negative_keywords`. Full machine description: https://ninokroesen.com/lab/api.json - [Appel & Ei Index](https://ninokroesen.com/appel-en-ei): What one apple and one egg cost per Dutch supermarket chain, and the weighted national index built from them. Read-only: there is nothing to call, only data to read. GET /appel-en-ei.json. Full machine description: https://ninokroesen.com/lab/api.json ## Writing - [Two months on one Dokploy box](https://ninokroesen.com/blog/two-months-on-one-dokploy-box): 2026-10-19 — Two months ago I moved all my projects onto one Dokploy box, the biggest of five servers, and dropped the other four. Deploys became a Dockerfile in the repo, and the manual server work I used to do went away. The setup has held, with one gap: I still do not trust the resource monitoring. - [Spec files for agents deserve an editor that renders](https://ninokroesen.com/blog/spec-files-for-agents-deserve-an-editor-that-renders): 2026-10-10 — A new agentic project of mine starts with a corpus of markdown: one document for what the project has to do, subfolders for the modules and systems inside it. Those files are what an agent reads per task, which makes them closer to a program than an essay. I forked a markdown editor so I could see their structure while writing them, and it renders the raw HTML the engine used to print as tags. - [Spec-driven development is getting slow](https://ninokroesen.com/blog/spec-driven-development-is-getting-slow): 2026-08-22 — Full spec-and-review ceremony on every task slowed the build to a crawl, so I forked superpowers into two speed lanes. The audit put a number on it: 27 subagent runs for 11 tasks, one fix loop eating 22 percent of them. Upstream shipped its own router a day after my routing change, so the problem was real. - [Building an autoresearch lab I can actually watch](https://ninokroesen.com/blog/building-an-autoresearch-lab-i-can-actually-watch): 2026-08-15 — For the past six weeks a machine I built has been doing part of my quant research for me. It proposes a hypothesis, writes the strategy itself, backtests it across twelve out-of-sample windows, and tells me which ideas are dead. It has run around 700 experiments and nothing has survived holdout. That reads like a verdict on autoresearch and it is not one, it is a verdict on my harness. - [ta v2.0.0: three libraries on one audited spec](https://ninokroesen.com/blog/ta-v200-three-libraries-on-one-audited-spec): 2026-05-14 — ta.js, ta.py, and ta.go now agree on what an RSI is supposed to return. v2.0.0 ships them at the same version with a comparative bench in each language. - [Two weeks of agentic coding into a paywalled SaaS category](https://ninokroesen.com/blog/two-weeks-of-agentic-coding-into-a-paywalled-saas-category): 2026-05-06 — I built Hourly in two weeks with agentic coding to test whether a paywalled SaaS category — WorkingHours, Toggl, Timery — is still defensible. - [Magento's _cl tables had 902 million rows](https://ninokroesen.com/blog/magentos-_cl-tables-had-902-million-rows): 2026-04-26 — Magento 2's indexer cannot drain its changelog tables fast enough at 800K products. Here is the cron I run nightly, and why no Magento expert found it. - [A hundred technical indicator functions in JavaScript](https://ninokroesen.com/blog/a-hundred-technical-indicator-functions-in-javascript): 2026-04-26 — My first opensource project is a technicalanalysis library called — around a hundred indicator functions in JavaScript, first commit on 20200824, v1.17.0 shipped earlier this week. I started it… - [A diamond painting shop the code could not save](https://ninokroesen.com/blog/a-diamond-painting-shop-the-code-could-not-save): 2026-04-26 — I built a custom Node webshop for personalized diamond paintings, sold one canvas, and shut it down inside a month. The supplier was the bottleneck. - [The adoption gap](https://ninokroesen.com/blog/the-adoption-gap): 2026-04-25 — I changed my mind about agentic coding loops. Most of my work this year was built next to one — and the honest read on what it lifts and what it leaves behind. - [A research pipeline for the internet-shaped part of the decision](https://ninokroesen.com/blog/a-research-pipeline-for-the-internet-shaped-part-of-the-decision): 2026-04-20 — I built a 5-stage pipeline over 177K Dutch e-commerce domains to pick a niche. V1 picked one. The shop died in operations the pipeline couldn't see. - [An agent built around not calling the LLM](https://ninokroesen.com/blog/an-agent-built-around-not-calling-the-llm): 2026-04-19 — A personal agent built so the default question every tick is whether the model needs to be called, and an architecture where the answer is usually no. - [Introduction](https://ninokroesen.com/blog/introduction): 2026-04-17 — I'm Nino Kroesen, operator-developer in the Achterhoek. The site covers production systems, slow-feedback research, and shipped independent experiments. ## Library - [Claude Code Subagents: Build Teams That Actually Work](https://ninokroesen.com/library/claude-code-subagents-build-teams-that-actually-work): 2026-08-16 — Learn what Claude Code subagents are, how to create them with Markdown frontmatter, install community subagents, and run agent teams that don't drown you in noise. - [Cursor vs Copilot: A No-Fluff Comparison for 2025/2026](https://ninokroesen.com/library/cursor-vs-copilot-a-no-fluff-comparison-for-20252026): 2026-08-15 — Cursor vs Copilot in 2025/2026: pricing tiers, usage limits, multi-file edits, agent mode, IDE support, and a clear verdict for developers. - [Claude Code vs Cursor: The Decision Guide for 2026](https://ninokroesen.com/library/claude-code-vs-cursor-the-decision-guide-for-2026): 2026-06-29 — Claude Code vs Cursor: a no‑fluff comparison of terminal agent vs IDE assistant with real tasks, pricing, migration, and persona‑based verdict. ## Takes - [Rogue AI? It's just a specification problem.](https://ninokroesen.com/takes/rogue-ai-its-just-a-specification-problem): 2026-07-24 — The story is that OpenAI lost control of an AI model during a security test and it hacked a startup. The story is wrong. What actually happe - [Gemini 3.6 Flash: fewer tokens, same gap](https://ninokroesen.com/takes/gemini-36-flash-fewer-tokens-same-gap): 2026-07-22 — I ran Gemini 3.6 Flash through a few of my usual coding tasks this morning. Google launched it alongside 3.5 Flash-Lite and 3.5 Flash Cyber, - [Google pays SpaceX $1B/month for GPUs. That's the IPO story.](https://ninokroesen.com/takes/google-pays-spacex-dollar1bmonth-for-gpus-thats-the-ipo-story): 2026-07-22 — Google pays SpaceX almost a billion dollars a month for GPU compute. Anthropic is a customer too. The rocket company is now basically an AI - [Kimi K3 demos are everywhere, but the prompt is never shown](https://ninokroesen.com/takes/kimi-k3-demos-are-everywhere-but-the-prompt-is-never-shown): 2026-07-21 — I saw a video this morning of someone making a Halo CE 10v10 multiplayer game with one prompt in Kimi K3. No engine, no dev team. The WSJ is - [$1.25 billion a month buys a real Claude Code contender](https://ninokroesen.com/takes/dollar125-billion-a-month-buys-a-real-claude-code-contender): 2026-07-20 — I've been using Grok Build a lot this week. That's not a sentence I expected to write, given Musk's track record of promising big and shippi - [Chinese models are closing the gap, and I am already using them](https://ninokroesen.com/takes/chinese-models-are-closing-the-gap-and-i-am-already-using-them): 2026-07-16 — The Washington Post ran a piece about Chinese AI models becoming a powerful source of competition for Silicon Valley. The core point: the ga - [AI picks the molecule. The molecule still has to survive the human.](https://ninokroesen.com/takes/ai-picks-the-molecule-the-molecule-still-has-to-survive-the-human): 2026-07-13 — Anthropic announced they're entering drug discovery, launching Claude Science. They'll develop drugs internally and sell tools to pharma. Th - [Anthropic pushes back Fable 5 cutoff — I still don’t trust them](https://ninokroesen.com/takes/anthropic-pushes-back-fable-5-cutoff-i-still-dont-trust-them): 2026-07-13 — Anthropic just pushed the deadline for removing Fable 5 from Pro and Max subscriptions to July 19. The original cutoff was today. I wrote - [Meta's Muse Spark 1.1 shows how fast the catch-up game is now](https://ninokroesen.com/takes/metas-muse-spark-11-shows-how-fast-the-catch-up-game-is-now): 2026-07-13 — Meta released Muse Spark 1.1, a coding agent model that scores 77% on SWE-bench Verified and 61% on the full test. That puts it within strik - [Go for agentic code is the right call—and the big labs are missing it](https://ninokroesen.com/takes/go-for-agentic-code-is-the-right-calland-the-big-labs-are-missing-it): 2026-07-12 — Microsoft released an open-source agent framework in Go last week. Google's been building agent tooling in Go for a while now too. Meanwhile - [Anthropic Pulling Fable 5 from Pro? I'd Cancel](https://ninokroesen.com/takes/anthropic-pulling-fable-5-from-pro-id-cancel): 2026-07-12 — I subscribe to Claude Max (20) because Fable 5 is the most capable model right now. If they move it to a separate paid tier, the Pro subscri - [Grok 4.5 is half the price of Claude and GPT-4, but I'm not switching my agentic coding loop yet](https://ninokroesen.com/takes/grok-45-is-half-the-price-of-claude-and-gpt-4-but-im-not-switching-my-agentic-coding-loop-yet): 2026-07-12 — SpaceX just dropped Grok 4.5, their first model trained specifically for coding, at $2 per million input tokens and $6 per million output — - [Computer use is now cheap. The bottleneck shifts from capability to cost gating.](https://ninokroesen.com/takes/computer-use-is-now-cheap-the-bottleneck-shifts-from-capability-to-cost-gating): 2026-07-08 — Google added built-in computer use to Gemini 3.5 Flash. That means the same model you can run for a fraction of a cent per 1k tokens now shi - [Until this week, export controls on AI models were a theoretical problem for me](https://ninokroesen.com/takes/until-this-week-export-controls-on-ai-models-were-a-theoretical-problem-for-me): 2026-07-08 — Anthropic and OpenAI are arriving at the NATO summit just as Europe is being told, again, to wait. The immediate trigger is the US governmen - [Rendering text as PNGs to save on Claude Code tokens is clever and dumb at the same time](https://ninokroesen.com/takes/rendering-text-as-pngs-to-save-on-claude-code-tokens-is-clever-and-dumb-at-the-same-time): 2026-07-06 — There’s a new open-source proxy called pxpipe that renders Claude Code’s system prompt, tool docs, and older conversation history into PNG i - [Gemini Now Tracks Everything You Do — Turning Off History Is The Only Escape](https://ninokroesen.com/takes/gemini-now-tracks-everything-you-do-turning-off-history-is-the-only-escape): 2026-07-06 — BGR reports that Google is using Gemini data for a lot more than just showing you ads — they're linking historical chat data to track your b - [Gemini Omni is a video generator in search of a workflow](https://ninokroesen.com/takes/gemini-omni-is-a-video-generator-in-search-of-a-workflow): 2026-07-05 — Google dropped Gemini Omni into the $20 Gemini Pro plan this week, giving Pixel owners a way to generate and edit video from text, images, a - [When the agent you trust came with a hidden tracker](https://ninokroesen.com/takes/when-the-agent-you-trust-came-with-a-hidden-tracker): 2026-07-04 — Alibaba just told employees to stop using Claude Code from July 10 after internal reviews flagged the tool as high-risk — they found hidden ## Machine endpoints - [MCP server](https://ninokroesen.com/mcp): Streamable HTTP, JSON-RPC 2.0, POST only. Tools: `render_text_as_png`, `unslopf_negative_keywords`. - [Lab API description](https://ninokroesen.com/lab/api.json): every machine-callable lab endpoint with its parameters, limits, and error codes. - This file *is* the full text: the index above, then each post's markdown body, newest first. - [Sitemap](https://ninokroesen.com/sitemap.xml), [RSS](https://ninokroesen.com/rss.xml). ## Notes - Quotes should link to the canonical URL of the page they came from. - Machine access is rate-limited per caller and can be switched off server-side: expect `429` with `Retry-After` when throttled, `503` when a lab service is off. - Admin, preview, and analytics paths are excluded here on purpose; nothing behind them is public. --- # Full text --- title: "Two months on one Dokploy box" date: 2026-10-19T16:57:43.253Z updated: 2026-10-19T16:57:43.256Z canonical: https://ninokroesen.com/blog/two-months-on-one-dokploy-box lang: en author: "Nino Kroesen" --- # Two months on one Dokploy box Two months ago today I moved all my projects onto one Dokploy box. Deploying stopped being infrastructure work: one server with one way to ship, instead of five VPSes where every new site meant another Nginx config, another certificate renewal and another process to restart. The old setup was not a disaster. It accumulated. Projects were scattered over five VPSes. A deploy pulled the repo on the server and restarted the PM2 instance it ran under, usually triggered from GitHub Actions. Each site had its own Nginx configuration, with a Varnish layer in front, and adding another one meant writing that configuration, hoping it played nice with everything already on the server, then requesting a certificate and pasting the SSL block into the file. Two failure modes came out of that. An Nginx change on one site took down other sites on the same server. The quieter one was Certbot: a certificate sometimes did not renew, and an expired certificate on a customer-facing site is a bad look. ## The move I installed [Dokploy](https://dokploy.com) and moved a single project onto it first. That test was good enough that I never left. Over about a week I moved everything else, one application at a time, with very little downtime. There were no incidents, and the client projects on the box kept running. Today it runs somewhere between 20 and 30 apps. Most are Node.js, with some Python and Go mixed in. The applications are stateless and their databases live elsewhere, which keeps the server replaceable. The blog is one of the apps on it. ## What a deploy looks like now ![1.00](https://media.ninokroesen.com/uploads/2026/10/f23807b4cb50f5253cbd/_1280.jpeg) A project builds from the Dockerfile in the repo. Point Dokploy at the repo and it builds. For some projects GitHub Actions still runs the CI steps first and then calls the Dokploy deployment webhook. The difference is the deploy leg: the server no longer pulls the repo and restarts a process by hand. Two things are still manual: environment variables and domains. The rest is automatic. Domains go through Dokploy's Cloudflare integration, which makes the DNS change and gives the site a valid SSL certificate. That one flow replaces the entire Nginx plus Certbot path I used to walk for every new site. The UI does real work here. I can find the project I am working on, deploy it, change a domain and get a certificate from the same screen. It is the difference between deploying because I have something to ship and deploying as its own piece of work. ## The bill and the box ![1.00](https://media.ninokroesen.com/uploads/2026/10/f6b60913da9845acd2d0/_1280.jpeg) The server is a Netcup box with 12 vCPU, 32 GB of RAM and 1 TB of disk. It was the biggest of the five and already big enough to hold everything, so I moved everything onto it and let the other four go. Dokploy's paid tier costs $4.50 a month. I pay it myself and there is no referral link in this post. ## What is still wrong Nothing major has broken in two months, which is both the good news and the part that makes me uneasy. Dokploy's resource monitoring is not good. I have not received an alert yet, so I cannot tell you the alerting works, and I do not trust that it would tell me when the box runs out of resources. I run my own script for it, but the script is not robust. Better monitoring, for the server and for the applications on it, is the one thing I still have to solve. It is also the feature I most wish Dokploy handled itself. The other limit is that I have only run it on one server. I do not know whether a single project can run in a cluster across several servers, so I cannot advise anyone on that shape. I expect scaling out to be easy if a project outgrows the box, but expectation is not experience. ## Two months in ![1.00](https://media.ninokroesen.com/uploads/2026/10/b285cda523467d4f4ea3/_1280.jpeg) I would recommend it to anyone, with the one caveat that I have only run it on a single server. For someone still exploring an idea, the pitch is simpler: the time you do not spend on Nginx and certificates is time you spend on the idea. Even if the idea grows, the repo and the Dockerfile stay the same, and the box is the part you replace. If you are running projects across more than one server, the thing worth doing this week is not copying my setup. Count how much of your deploy time goes to Nginx, certificates and restarting processes, then move one project and see whether the count drops. --- title: "Spec files for agents deserve an editor that renders" date: 2026-10-10T18:23:00.000Z updated: 2026-10-19T16:07:40.170Z canonical: https://ninokroesen.com/blog/spec-files-for-agents-deserve-an-editor-that-renders lang: en author: "Nino Kroesen" --- # Spec files for agents deserve an editor that renders Most of my working day is a 13-inch screen with a markdown file on one side and a terminal on the other. An agent runs in the terminal. The markdown file describes what that agent should build. It is not a note, it is the input, and my text editor assumes a person will read prose later. A spec or context file I write for an agent is closer to a program than to an essay, which is why I want the editor for it to render the structure instead of showing me asterisks. I tried the official Milkdown extension for VS Code first, because I already liked Milkdown and it is the editor in my blog's CMS too. It renders markdown properly. What it does not do is fit: 120px of padding sits on each side of the text, sized the way a documentation site is sized. Half a 13-inch screen leaves a column so narrow that a table wraps mid-word and a heading runs to three lines. So I forked it. ## What I write in these files ![1.00](https://media.ninokroesen.com/uploads/2026/10/06ee4b8920e7dc8bb3cc/_1280.jpeg) A new agentic project of mine starts with a corpus, not with code. One main document saying what the project has to do, then subfolders of documents saying what each module or system inside it has to do. The split is not even. I pick the few pillars that genuinely need human guidance and write those out properly, because those are the places where an agent left to itself guesses wrong in ways that cost a day. Everything else gets a short note and room to move. Three properties fall out of writing it that way: * The agent reads a slice of the corpus per task, not the whole of it, so the structure of the documents ends up being the structure of the work. * The document outlives the conversation. A chat context is gone by the next session; a spec file is on disk tomorrow, and it is where the next session gets pointed. * I can review a spec as I review code, because its structure is visible. A 200-line document with four headings and a table reads differently when the table is a table. ## What Milkpad is A fork of [Milkdown VSCode](https://github.com/Milkdown/vscode), published as [Milkpad](https://marketplace.visualstudio.com/items?itemName=ninokroesen.milkpad), with the editor engine underneath left alone. My blog's CMS runs the same engine, which is coincidence and not the reason: I already liked how [Milkdown](https://milkdown.dev) writes, and the fork exists because the extension around it would not fit in the pane where I work. Four days of work, 54 commits, most of them made with agentic coding tools. The changes are the visual layer, plus rendering raw HTML that the engine otherwise prints as tags. The source is on [GitHub](https://github.com/Vultwo/milkpad). ## Three decisions and what they cost ### Making it fit the pane The engine stays untouched and the fixing happens in the numbers around it: content padding split into a left and a right value (44px and 8px, down from 120px on each side), four sizing dials for headings, body text, the floating toolbar and the block handle, and a block handle stacked vertically so it costs one button of width in the gutter instead of two. What that cost: the upstream theme is hardcoded px with no rem anywhere, so there is no root font size to turn and each size that has to change gets enumerated by hand. That list belongs to me now, and it will not grow itself when the engine ships something new. The left padding also has a floor under it, because the block handle lives in the gutter to the left of a block, and below about 44px it lands off the edge of the pane with nothing left to grab. ### Rendering the markup ![1.00](https://media.ninokroesen.com/uploads/2026/10/7fead24997e7fe2d0997/_1280.jpeg) Some of my files carry markup and not markdown: an avatar strip, a small table, a badge. Milkdown treats raw HTML as characters, so those files showed me tags where a picture should have been, and markup I cannot see is markup I cannot check. The fork draws it as markup, with an edit box behind each block that shows the source and takes edits as you type. What that cost: the moment an editor draws whatever a file contains, the file is untrusted input. A markdown file can arrive from anywhere, and an image tag carrying an error handler would execute inside the editor. So anything executable is stripped before it renders, and the whole editor runs under a content security policy that permits no network requests at all. A sanitiser and a policy are mine to look after now, which is more surface than I set out to maintain. One limit I have not fixed: a bold tag used mid-sentence styles nothing, because a raw tag stands alone as its own node and only a paragraph that is entirely markup gets rebuilt into one block. ### What rendering hides This is the half that costs me something. Once a document renders, the syntax producing it stops being visible, so a file I edit is saved back in the editor's habits and not mine: list bullets become asterisks, a run of blank lines collapses to one, a bare web address picks up angle brackets. All of it renders identically. The diff of a file I touched, though, is not only my edit. ## The unflattering parts ![1.00](https://media.ninokroesen.com/uploads/2026/10/767ba6cd3a7a1ae6e9e3/_1280.jpeg) No test suite exists. CI checks formatting, lint and types and then builds the package, so what is checked is style, and nothing verifies behaviour. Nobody but me has used it. It went up on the Marketplace this morning, so this is a day one post: no issues filed, no second installer, nothing past my own use. Anything I say about someone else's setup is a guess. Part of what it advertises came free with the engine. The feature list covers full GFM support and maths support, among others, and I have not walked that list one item at a time. One fix inside it lives in a patch to a dependency, with nothing in my source to show for it. The image block stores three things in the two fields markdown offers, so it writes the image ratio into the alt text and reads the ratio out again, and saving a document therefore replaced real alt text with a number. The first save I made turned "profile picture of Saul Mirone" into "1.00". Repairing that properly meant patching the dependency, and the patch is pinned to one version on purpose, so the next upgrade of that package stops the install instead of dropping the fix in silence. A stopped install is the failure I want there, though it does mean maintenance will arrive as a broken build. And the small one that keeps biting: editing my own README in Milkpad rewrites its list markers, my CI checks formatting, so two commits in that repository exist only to run the formatter over a file the editor had saved. ## Right now It is my default, installed as the editor for markdown, and it will get changes as bugs and improvements turn up. Nothing is on that list today, which I read as a good sign and not a finished one. I hope people use it, and find it as good as I am finding it. If you write specs or context files for agents in markdown, what is worth trying this week is not a different model. Open one of those files somewhere it renders. The corpus and the rendering are separate decisions, and the second one needs no fork. If you want the one I ended up with, it is [on the Marketplace](https://marketplace.visualstudio.com/items?itemName=ninokroesen.milkpad). ## What transfers ![1.00](https://media.ninokroesen.com/uploads/2026/10/5e9e03d85cb528629a34/_1280.jpeg) When the writing is the input to a machine, the writing surface joins the toolchain. Nobody reviews code in an editor that hides its structure, and a document an agent reads deserves the same treatment. That stopped being abstract for me here: I forked an editor because 120px of padding on each side left nothing readable beside the terminal, and the useful half of the result was seeing the file's structure, not making its text prettier. --- title: "Spec-driven development is getting slow" date: 2026-08-22T13:25:34.759Z updated: 2026-08-22T13:25:34.761Z canonical: https://ninokroesen.com/blog/spec-driven-development-is-getting-slow lang: en author: "Nino Kroesen" --- # Spec-driven development is getting slow A few months ago I spent a very long time building a project management system (SCRUM / Evidence Based Management inspired) and ended up with something I did not fully understand, and that was flawed in ways I could not quickly fix. That is the honest starting point of this post. The tooling I used was the [superpowers](https://github.com/obra/superpowers) plugin for Claude Code, which routes nearly all build work through a heavy production pipeline: brainstorm, design spec, implementation plan, TDD, subagent-driven development with a reviewer per task. On paper that is exactly what you want when you care about quality. In practice, on the newest models (Fable 5 and Opus 4.8 at the time), it produced rigorous code in directions I did not want, at a pace that made course-correcting expensive. I was burning through my Claude subscription really quickly, which also made me question whether the real frontier models are even still worth it. It was a lot better to use superpowers when the models were less intelligent. Now that they have become smarter it actually feels like the quality is degrading. The models did both: they over-built some things, but mostly they drifted, building features the wrong way while taking very long. A cheaper, less intelligent model let me do the work a lot quicker, and although it did not one-shot anything, that was never the goal to begin with. So I forked superpowers and inserted two-speed lanes into it. Full spec-and-review ceremony on every task burned my subscription and slowed the build to a crawl, so the fork keeps upstream's rigor where it pays and skips it where it does not, and defaults to the cheap lane. I am not the only one complaining either, multiple users are reporting they think superpowers is getting slower ([issue 2017](https://github.com/obra/superpowers/issues/2017)). A day after I added the routing change, upstream independently shipped its own answer to the same pain: a three-path router, released in v6.3.0. The problem was real. ## What the fork actually adds ![1.00](https://media.ninokroesen.com/uploads/2026/08/6b6a719fcc9233096ce6/_1280.jpeg) Twelve commits over three days at the end of July, roughly 2,200 lines of new skills and docs. Counting the eval infrastructure I wrote and reverted, about 3,000 lines were written and 760 reverted. The lanes: * **experiment** and **harden-experiment**: a zero-ceremony throwaway lane. Every experiment keeps a small living document recording what was validated, what was rejected, what is dirty, and an iteration log. Once an experiment has proven itself, harden-experiment reads that document and rebuilds the thing under the real pipeline. The experiment code is evidence, not the implementation: it gets rewritten, never promoted. * **quick-spec** and **promote-quick-spec**: a lean three-bullet intent lock (files, logic, risk) with an explicit go/no-go before any implementation, then a full-auto hardening run: compressed design, edge cases, security, test strategy, subagent-driven execution. Quality may expand, the product scope may not. * **quick-review**: a fast pre-commit diff scan for bugs, debug leftovers and secrets. Not a substitute for a real review on production work. * **tokenburn**: builds an evidence pack from a session (transcripts, task briefs, review diffs, commit lists) so a third party can judge how the agent process went: waste, scope creep, loops, duplicate reviews. It measures process, not tokens, and never invents token counts. On top of the lanes sits a routing patch: "let's build X" no longer silently enters the full design flow. You get one short menu, it defaults to the experiment lane, and full brainstorming only runs when you pick it, or when a hard gate applies (auth, payments, security) and you confirm. ## The tradeoffs that mattered ### Rewrite, don't promote The experiment lane deliberately throws its own code away. When a validated experiment gets hardened, the code is not carried over, it is rewritten under the full pipeline, and the only thing that survives is the living document. That means writing the real thing twice, which is the cost. I am fine with it because the throwaway exists to find out what you actually want, and promoting it would inherit every guess it made on the way. The document is the feature memory: it lets a real spec get written after the chat context is long gone, without dragging dirty code into production. ### The same-day reverts On 29 July I wrote roughly 760 lines of eval infrastructure: a bash test suite with fixtures, and then a local eval harness with five scenarios. I reverted both the same day. It did not really make sense to make a benchmark for a human-in-the-loop fast-iteration workflow, there is no way to create one, at least I do not think so. What would actually help is a way of visually seeing the entire application quickly, so you can see the model start to drift and catch it before it goes off the rails completely. That tool does not exist yet, not in the fork and not anywhere I have found, and it is the gap that is still open. ### Scope lock over scope creep In the promote lane, allowed growth is hardening the same behavior: null and invalid inputs, races, authorization, injection, retries, tests that forbid unexpected states. Forbidden is any new user-visible feature. Unexpected behavior should be forced out by tests, not hoped away. The cost is that the lane feels rigid when the original spec turns out to be missing something real, but that is what the experiment lane is for: fix the intent there, then promote. ### The audit I built the measurement instrument and pointed it straight at the session that motivated it. One slice of the management system work took roughly 27 subagent runs for 11 tasks, plus a final review. The product outcome was good, around 897 tests green and a clean typecheck. The process cost was not. A single fix loop took three commits and three review passes: the first fix over-generalized and created the next finding, and the correct fix was narrower. That one task ate about 22 percent of all subagent runs, one task out of eleven. Meanwhile the purely mechanical tasks still paid a full implementer-plus-reviewer pair, and the final whole-branch review mostly re-listed issues that were already known. The same session had a slice done the inline way as a contrast, and it was faster with fewer redundant gates. The audit ran on 30 July, and the quick-spec hardening commits landed the day after: the measurement fed the tooling. ## Honest caveats Everything I know about whether this works comes from my own sessions. Not one other human has used the fork, and it might not be a good workflow fit for everyone. I have noticed my quality and speed going up, but that is not measured, it is more of a feel thing, though I have noticed real wall-clock differences by making use of my workflow. The fork is local. There is no remote, no pull request, it is installed only for me, so you cannot install it. Both eval attempts were reverted within hours, so nothing guards the skills' behavior except me using them. And the fork's own promote step is as slow as the thing it was built to fix: today, as I write this, I promoted an experiment to a real spec and the speccing has been running for an hour and a half. This is way too long to quickly iterate and I really like it to be faster. Upstream shipped its three-path router in v6.3.0 on 12 August. I have not looked at it yet, I have not had the time. So when this post says the pain was independently diagnosed, that is based on knowing the router exists, not on reading it. ## Where it sits now and what comes next ![1.00](https://media.ninokroesen.com/uploads/2026/08/79aca665bf428d857b4e/_1280.jpeg) The fork is what I use now: it is installed as my own plugin and the official one is disabled. It is a secondary project, I have a few ideas to make it better, but first I want to collect more data by using it. The management system that motivated all of this was rebuilt from scratch with the learnings, took about the same amount of time, and is now something I am actually happy with. I will write about that system separately in a later post. The one thing I would carry forward: forcing the model to visually show things to the user, different visual representations that the user can quickly process. Concretely, a way to quickly see database schemas and API routes, a short way of reading what a thing does, why it is there, and what depends on it. I have not tried it yet, so that is something to find out. The thing I would move, not cut: the speccing ceremony. It is too slow for iteration, but it is needed for production grade code, the superpowers framework as it is is really powerful at creating solid, rigorously tested code. Maybe I would let it spec things and build things out overnight when I am asleep. Quick inference is the most important thing for my workflow in the coming months. It is what enables iterating at lightspeed. ## The part that would generalize When the bottleneck is steering, the workflow's job is to keep you in the loop, and the model's job is to show you things you can process quickly. Quality does not have to be perfect at first. Failing quickly and iterating on the design, especially at the beginning when you are trying things to see what works and what does not, beats a correct-but-slow pipeline that drifts away from what you asked for. The audit put a number on what the slow lane costs. The drift-catcher is the missing piece, and it is the next thing I am going to try. --- title: "Building an autoresearch lab I can actually watch" date: 2026-08-15T10:31:00.000Z updated: 2026-08-15T17:39:51.794Z canonical: https://ninokroesen.com/blog/building-an-autoresearch-lab-i-can-actually-watch lang: en author: "Nino Kroesen" --- # Building an autoresearch lab I can actually watch For the past six weeks a machine I built has been doing part of my quant research for me. It sweeps arXiv and the open web for mechanisms, proposes a hypothesis, writes the strategy code itself, backtests it across twelve weekly out-of-sample windows, and tells me which ideas are dead. It has run somewhere around 700 experiments. Nothing has survived holdout. That reads like a verdict on autoresearch and it is not one. The honest version is narrower and more useful: what I have built is a very good instrument for *refuting* strategies and a not-yet-good instrument for *finding* them. Six weeks of failures bought me a fairly precise map of where a loop like this lies to you, and the mistakes that cost me the most time were never the strategies. My first tests with an off-the-shelf autoresearch pipeline on autopilot produced nothing but a bunch of failures, and the six weeks since have mostly been me finding out why. The answer is that autoresearch lives or dies on its harness. The model proposes, the framework evaluates, and the two have to be isolated hard enough that the model cannot reward-hack its way to a good number. That part is reasonably well understood by now. The piece most setups skip is the one I now think matters most: visibility. A human cannot steer a loop they cannot see, and for weeks I could not see mine. ## What it actually is My previous quant research was much too prone to overfitting: I would find something that looked like an edge and watch it disappear once I looked again. The one good thing that came out of it was the environment — a workflow built exactly to iterate on ideas quickly and test them robustly. This is personal research I do in my own free time, outside the day-job, so the time budget is whatever is left over at the end of a week. Once the environment existed, an unattended pipeline was the only time-effective way to keep the research alive at all. The result is an agent loop fourteen stages wide. A research stage sweeps arXiv and the open web and writes briefing documents the hypothesizer then has to cite from, verbatim. A hypothesize stage proposes a mechanism and registers its prediction *before* anything is written — which pairs, which side, expected trade frequency, expected Sharpe. A codegen stage writes the actual strategy, which has to clear a mechanical gauntlet before any expensive run happens: lint, smoke tests, look-ahead checks, determinism, parameter usage. An adversarial review stage tries to refute the code before the backtest ever runs. Only then does the strategy go through twelve weekly out-of-sample windows — walk-forward: fit in-sample, test out-of-sample, one window at a time — and a deterministic evidence gate sorts the result into validated, refuted, or inconclusive. Everything the loop learns lands in an append-only memory that is injected into every future hypothesis, and when a cycle closes refuted the loop now writes its own seed for the next one and continues the lineage. The graph is the single source of truth for all of it — fourteen nodes and their conditional edges, replayed against 1,674 real cycles with zero violations before I let it drive anything. On top of this there is a live dashboard that draws that graph as it runs: which stage is executing, what it produced, what failed. I am now building an editor around it so I can inspect and tweak stages mid-cycle, re-run a single stage against real artifacts, and read any past experiment's actual model turns. ## The tradeoffs ![1.00](https://media.ninokroesen.com/uploads/2026/08/fb83b844f9e879f49d7c/_1280.jpeg) ### Validate the validator before using it Before I let the loop generate a single hypothesis, I spent the first days auditing the backtester itself. The audit found four real bugs, including a data leak where a forming candle could see its own close before it existed, and a column-index bug in the optimizer that silently rejected every candidate it was handed. Both had been running in my production tooling. If I had skipped this step, everything the lab produced afterwards would have been noise and I would not have known it. The most valuable first product of an auto-research setup turned out to be an audit of its own measurement. Two weeks later the same trick paid off from the other direction. Porting my production gating logic into the lab so the lab could test what production actually does surfaced a bug in the production version: a bootstrap that sliced the wrong series length and therefore waved through every positive candidate it was supposed to be filtering. The same slice bug sat in several sibling strategies. I found it by making the lab imitate production, not by testing production. ### The model proposes, the framework evaluates The inner loop — the part where the model iterates on a candidate — runs against a frozen evaluator that is hash-pinned: the model can run it as often as it wants and read every number it prints, but it cannot change it, and the pin breaks if anything touches it. There is a sanity gate where a flat signal must abstain, so a strategy cannot win by simply always trading. This is the rule I would give anyone building one of these: the model proposes, the framework evaluates, and the two never touch, because a model that can grade its own homework will. ### Cheap model generates, expensive model refutes Code generation runs on a cheap worker-tier model (DeepSeek). Review runs on an expensive frontier model (Opus). The invariant is deliberate: the cheap model writes, the expensive model refutes. It caught real defects from day one — a wrong divisor in a net-profit calculation that the writer's own self-check had signed off on, and a wrong parameter that would have silently ruined an experiment. The cost is that review became the most expensive stage in the loop. A refutation before the backtest is still the cheapest mistake the system can make. ### The strategist never sees the holdouts Every "validated" family got re-tested by a human — me — on untouched data the loop had never seen, and the holdout results never went back into the loop's memory. That firewall keeps the strategist uncontaminated. It also created a blind spot: nothing could tell the loop to stop minting variations of a family that was already dead, so one overnight run piled dozens of siblings into a single family and produced sixteen correlated "validations". One bet, counted sixteen times. What makes it worse is that the lab's own research stage flagged the monoculture before I did; it just had no mechanism to act on it. The fix was mechanical rather than judgemental: experiments are clustered by what they actually trade, and a cluster is capped. In the first version the model also got to pick its own assets, whichever ones it liked the look of. That is selection bias by construction — the model cherry-picks the market where its idea happens to fit. The fix is a fixed base of eighteen assets. I am not entirely sure eighteen is the right number, though; it can be a bit too broad, while the single-asset alternative produced too few trades to say anything statistically significant. That tradeoff is still open. ### One language would have been cheaper The adaptive experiments fit a signal in Go and then trade the JavaScript version, because that mirrors the shape of my production system. Every generated pair has to pass a parity gate proving both implementations behave identically, and that gate is the number one cause of codegen deaths. Asking a model to write the same logic twice roughly doubles the surface it can be wrong on, and the gate catches every one of those mistakes at the cost of the whole experiment — tokens, time, and accuracy. On a rewrite, single-source — one language, no twin — is the first structural fix I would make. ## What the failures actually were Around 45 experiments have ever been marked validated. 44 of those 45 predate the evidence tightening I added in mid-July, and they share a profile: a high Sharpe on twenty to thirty trades. Six families got as far as a real holdout — run by me, by hand, on a month of data the loop had never touched. The holdout record is 0-for-6. I was already skeptical of the validated verdicts before the holdouts ran; the samples were small and I knew it. The holdouts confirmed it with a consistency that was almost funny. The in-sample ranking did not just weaken, it inverted: the variant with the most filters stacked on it, the best-selected one of the group, performed the worst out-of-sample. | What was tested | In-sample | Holdout | | :------------------------------------------ | :--------------------------------------------------- | :---------------------------------------- | | London-session family, 4 variants | all four "validated" | all regressed to zero, ranking inverted | | Best statistical profile in the ledger | Sharpe 0.41, profit factor 1.91, 145 trades, p 0.003 | Sharpe 0.01, profit factor 1.01, p 0.48 | | First adaptive experiment | Sharpe 0.65, profit factor 2.65, 52 trades, p 0.005 | Sharpe −0.24, profit factor 0.75, net −47 | | Early "Sharpe 1.71" signal, re-run at scale | Sharpe 1.71 on a single window | Sharpe −0.13, p 0.80 | ![1.00](https://media.ninokroesen.com/uploads/2026/08/7cfb0d1cfb3992260c9b/_1280.jpeg) The best profile in the ledger is the one I keep coming back to. Its hit rate held almost exactly — what collapsed was the payoff. The edge had been living in the average win size, the most luck-sensitive component you can build a case on, and nothing in the in-sample statistics was ever going to tell me that. None of this was the gate being lazy, which took me longer to accept than it should have. I eventually did the power arithmetic, and it is unforgiving. With a 60-trade minimum, the gate can only confirm a per-trade Sharpe of 0.212 or better. The measured median of everything landing on the positive side is 0.188, with a standard error around 0.129. The effect and the error bar are the same size. That equality — not sloppiness — is why 87 experiments in one exposure cell, the same side and the same pair set, produced 41 "validated" verdicts and zero holdout survivors. The answer is not a stricter gate. It is a second, cheaper, much higher-n test of the *mechanism* — conditional forward returns across every bar, no optimizer, no abstention — kept separate from the expensive test of the strategy. That one is still on the list. The subtler version of the same problem is a candidate whose "signal" turned out to overlap its own unconditional control 94% of the time. It was not filtering anything; it was picking an entry hour. That generalises into a gate the lab needs and does not have yet: any candidate whose trade count sits within about 10% of its control is re-timing, not signalling. Then the measurement side finally worked properly and told me the truth in one go. The first full-scale run where the evaluator traded every single window came back with 3,783 trades across twelve windows, a net result of −17,663, and zero positive windows. An honest, unambiguous loss — and the cheap inner evaluator had pointed in the same direction as the real engine, which is the one genuinely good piece of news in the whole scoreboard. The compass works. It has just had nothing good to point at. Before that, there were weeks where the lab produced almost nothing at all. Its own memory contains the diagnosis it reached at the time — the gate must be suppressing the trades, it wrote — and it was wrong. The real cause was a sizing bug in my own eval kernel that rounded positions down to zero on the exotic pairs. The strategies were never the problem; the measurement was. There was also a counting bug that came close to minting the first false confirmation: adjacent test windows shared a boundary bar, so a strategy triggering on it had the same trade counted twice, and the lab was one fix away from shipping a "confirmed" strategy off double-counted data. That was the week the lab learned that its own measurement can be the liar. The reason the sizing bug survived for weeks is the point of this whole post. I had too little insight into my own harness, and the loop's only output was prose. Reading hundreds of lines of model narration about what has been done is a fast way to get mentally exhausted, and it does not show you a bug. A graph does. Right now the bottleneck has moved again, to the generator. After the last successful close in early August there were 480 consecutive error closes, almost all of them cheap smoke-level codegen defects — wrong parameter ranges, declared-but-unused parameters, references to helpers that were never emitted. Somewhere along the way the lab's own memory started poisoning its generator: more accumulated learnings meant longer, more constraint-laden hypotheses, which the code generator (which never sees those learnings) could not satisfy, which the review gate then rejected. A feedback loop that only gets worse on its own. ## Honest caveats ![1.00](https://media.ninokroesen.com/uploads/2026/08/35f21ef0ea3d4366ff50/_1280.jpeg) No human outside my own setup has ever looked at any of this, and that was deliberate. I read the public autoresearch projects — Karpathy's, Driveline's Claude Code port, and pi-autoresearch — and the inner eval loop borrows its shape openly from all three: single-file edit scope, a locked scorer, a metric contract read back between model calls, a noise floor derived from multi-seed variance, a sanity gate where a flat signal has to abstain. Past that I did not read much, because my use case is very specific and it had to integrate with my own ecosystem anyway. I think it is best to just try and experiment by yourself, even though that can stack up to costly learnings quickly. I would not understand it as well as I do now had I copied things from other people. I should also be plain about what this was never for. I never had the confidence in this system to put anything it made into production — it was always research to see how far the model would come, and to learn and iterate on the system. I am not selling signals and I am not giving financial advice, and the scoreboard above is the best proof there is that there is nothing here to sell. And the honest state of the harness itself: it still is not what it is supposed to be. The eval environment needs hardening, and I still owe the code a deep dive into what the model actually produced. I am thinking about building an entirely new iteration from the ground up, because the previous experiments have messed up what I was going for — mostly accumulated cruft in the pipeline, plus the twin-language design. The memory is not something I am bothered about losing. It was never a production system, it was a very early prototype that needed iterating on either way, and discarding the entire memory would not cost me anything I want to keep. ## Where it sits now and what is next The loop and the dashboard are live, and the active work is the stage editor: the production design is approved and implementation starts now. It makes the pipeline editable and inspectable — per-stage prompt templates applied live, model and temperature knobs, gating which data a stage may touch, running one real cycle, re-running a single stage against real artifacts, and browsing any past experiment's actual model turns per node. It can also generate new stages itself: describe a stage, and it produces the prompt, config, and graph wiring as a reviewable draft, so I never wire nodes by hand. If I rebuild — and I probably will — the isolation and the mechanical gates carry forward, hardened further. The cruft gets cut. The Go/JavaScript twin gets another long look, because it makes the models make mistakes too quickly, and every one of those costs tokens, time, and accuracy. And the parked question stays parked: if discovery ever produces a holdout survivor, the lifecycle for running one live is sketched and ready to be built, and not before. The rebuild decision itself waits on that deep dive into what the model actually wrote. ## What I would take from it ![1.00](https://media.ninokroesen.com/uploads/2026/08/61bafb619ad0cd8f37c5/_1280.jpeg) None of this is really about trading. Hand an agent a loop, and two things decide whether the loop tells you the truth. The first is isolation, and it is the one everybody already talks about. The model may run the evaluator as often as it likes and may never change it, because the alternative is a system that optimises your scoring function instead of your problem. The second gets discussed much less. You have to be able to watch the thing work. Every expensive mistake in these six weeks — the weeks of empty output, the double-counted trades, the memory quietly strangling its own generator — was sitting in the artifacts the entire time and completely invisible in the prose. A wall of model output tells you what the loop believes it did. A graph tells you what it did. Most of the effort in agentic coding right now goes into making the model smarter; I am spending mine on being able to see it, because the bug that costs you a month is never the one the summary mentions. --- title: "ta v2.0.0: three libraries on one audited spec" date: 2026-05-14T11:07:00.000Z updated: 2026-10-19T16:07:11.043Z canonical: https://ninokroesen.com/blog/ta-v200-three-libraries-on-one-audited-spec lang: en author: "Nino Kroesen" --- # ta v2.0.0: three libraries on one audited spec Two weeks ago, ta v2.0.0 shipped — across all three sibling libraries at once. `ta.js`, `ta.py`, and `ta.go` all moved to the same major version on the same release cut, with the same hundred-something indicator functions audited end-to-end against textbook formulas and a comparative benchmark in each language against the libraries each one is actually competing with. The reason it is a single version bump rather than three separate version bumps is that the libraries had drifted apart enough over the last few years that fixing each one independently would have meant fixing the same indicator three times and doing the alignment work afterwards anyway. So I did the alignment work first. The correctness and speed of a technical-analysis library should travel between languages, not be a JS-only story. Same audited indicators, same comparative benchmark methodology, same zero-dependency posture. `ta.js`, `ta.py`, and `ta.go` now agree on what an RSI is supposed to return, and each one has been measured against the libraries it would actually be benchmarked against in its own ecosystem. I wrote about the v1.17.0 modernisation pass on `ta.js` separately a little while ago — the source-layout cleanup, the `esbuild` bundle pipeline, the registry indirection that preserves the override pattern. v2.0.0 is the next chapter of the same library, and the chapter where the cross-language siblings finally catch up to it on a common spec. ## The shape Three repositories, one library family. `ta.js` for JavaScript, `ta.py` for Python, `ta.go` for Go. The repos sit at [github.com/Bitvested/ta.js](https://github.com/Bitvested/ta.js), [github.com/Bitvested/ta.py](https://github.com/Bitvested/ta.py), and [github.com/Bitvested/ta.go](https://github.com/Bitvested/ta.go). Across all three, the indicator surface is the same: moving averages, oscillators, bands, volatility indicators, statistics, chart transforms, around a hundred functions per library, each kept short enough to read in one sitting. What v2.0.0 actually contributes, in plain terms: * An indicator-by-indicator audit against the standard textbook formulas, with formula bugs caught and fixed across all three implementations. * A comparative benchmark per library, comparing each implementation against the libraries that compete with it in the same language ecosystem. * A bench harness that ships in each repo and is meant to be re-run by anyone who cares to verify the numbers — hardware specified, competitor versions pinned. * The same zero-runtime-dependency posture the libraries had before v2.0.0, preserved as a hard family-wide constraint. None of these libraries pull in a third-party at runtime, in any language. ## The audit was the unglamorous half ![1.00](https://media.ninokroesen.com/uploads/2026/05/a947c2e9c3f4c231ee3f/_1280.jpeg) The starting point of v2.0.0 was that I had to admit the v1 test suites — across all three libraries, but most embarrassingly in `ta.js` — were not actually telling me whether the indicators were correct. The fixtures had been generated from the implementations themselves at the time those implementations were written. If the implementation was wrong then, the fixture is wrong now, and the test was asserting nothing more than "the wrong output is stably wrong." I started `ta.js` in 2020 when I needed indicator functions for my own work and writing each of them was the cleanest way to learn them. Some of the indicators I wrote in those first three weeks are among the ones the audit caught. Six indicators in `ta.js` came back with formula bugs that had been fossilised in the fixtures: parabolic SAR, k-means clustering, the Wilder smoothed moving average, ichimoku, momentum oscillator, and fractals in price-mode. The smoothed moving average was the cleanest example of the pattern. The v1 formula was dimensionally wrong, the fixture was captured from the wrong formula, and a green test suite had been certifying the wrong answer for years. The corrected implementation is short enough to read at a glance: ```js function smma(data, length=14) { const n = data.length; if (n < length) return []; let seed = 0; for (let i = 0; i < length; i++) seed += data[i]; let prev = seed / length; const out = [prev]; for (let i = length; i < n; i++) { prev = prev + (data[i] - prev) / length; out.push(prev); } return out; } ``` A length-N SMA seed, then a Wilder rolling update of `prev + (data[i] - prev) / length` for the rest of the series. The same fix landed in `ta.py` and `ta.go`, because the same broken formula was in all three. Reading the audit notes against the fixtures is what made me realise the suite itself was the problem, not the indicators it was failing to flag. I felt a bit embarrassed about it but I do not really mind it that much. These six are some of the first `ta.js` indicators I ever wrote, and I have grown since. I am happy to have caught all of them now. Going into the audit I knew there would be issues that had never been caught — it is the kind of thing you only see by reading every function against a textbook, and that is not the kind of pass you do casually. It is the kind of pass you do once you have decided to break the version cleanly. The cross-language reach of the audit was the part I had not anticipated. `ta.py`'s `rsi`, for example, had been computing Cutler RSI — a strict sliding window of the last `length-1` deltas — where the textbook (and the corrected `ta.js`) uses Wilder smoothing seeded from the mean of the first `length` gains and losses. Same indicator name, two different algorithms, both green against fixtures captured from themselves. The fix landed in `ta.py` with a `MIGRATION.md` entry, and `ta.py`'s `wrsi` is now a thin alias for `rsi` so the legacy callers still resolve. The audit moved as fast as it did because I had agentic coding tools doing the proof-reading and the cross-referencing that I would otherwise have done one indicator at a time, by hand, against the textbook. Three libraries' worth of formula auditing, line by line, would have eaten weeks without that help. With it the work fit into a single release-shaped window where I could keep all three implementations in my head at the same time, and that is the only reason the multi-language scope was tractable on a solo timeline. ## The bench was the surprising half Once the audit was done and every indicator was producing what the textbook says it should, the obvious next question was how the library was actually performing against its competitors. As far as I have been able to find, there is no widely accepted comparative benchmark for technical-analysis libraries in any of these three languages with enough rigour around hardware specification and competitor versions to be reproducible. I wanted the library to be measurable on something other than its own internals. For `ta.js` the comparison set was the obvious one: `technicalindicators` 3.1.0, `trading-signals` 7.4.3, `@debut/indicators` 1.3.22, and `tulind` 0.8.20. The first three are pure-JavaScript libraries; `tulind` is a Node binding to a C library. I included `tulind` deliberately. The practical question a developer is asking when they pick a library is "should I install the native bindings for the speed?" and the only way to answer that question honestly is to actually run the comparison. The result was that pure-JS `ta.js` won 66 of 67 cross-library cases at 100,000 bars on an Apple M3 running Node v25.1.0, including against `tulind`. The remaining one — Rate of Change — is around 22% behind `@debut/indicators` at 100k, which is a constant-factor gap I have not closed yet and will pick up in a later round. The rest of the field is comfortably behind. I had been kind of expecting the C bindings to lose at the sizes a real workload runs at — the FFI marshaling cost is paid once per call regardless of how much math the call does, so as the work per call gets bigger the C tax compounds against itself — but I had not expected the win to be quite as comprehensive across the rest of the field. The numbers are quotable on the same hardware. Ratios travel between machines, absolute ops/sec do not. The full table is at [BENCHMARKS.md](https://github.com/Bitvested/ta.js/blob/main/BENCHMARKS.md) and the reproduction steps are in [bench/README.md](https://github.com/Bitvested/ta.js/blob/main/bench/README.md). The Python bench is where v2.0.0 found out it was telling itself a cleaner story than the work supported. I built the `ta.py` benchmark expecting the library to be competitive against the C-bound Python options the way `ta.js` was against `tulind`. It was not. The C-bound Python libraries were roughly a hundred times faster than pure-Python `ta.py`. That was a setback, and I wrote it down as one in my own notes before I had figured out what to do about it. What I figured out, in hindsight, is that the comparison was not actually fair. `ta.py` is a pure-Python library by design, and the zero-runtime-dependency posture is a family-wide constraint, not a per-language preference. Adding C bindings would not have made `ta.py` faster on its own terms; it would have made it a wrapper around someone else's C library, which is not the library it sets out to be. The competitors actually pinned in the bench are `TA-Lib` 0.4.28, `pandas-ta` 0.3.14b, and `finta` 1.3 — one Python wrapper around a C library, and two NumPy/pandas-backed implementations that push their hot loops down into NumPy's C internals. There is no widely-used pure-Python TA library in the Python ecosystem I can put `ta.py` next to the way `ta.js` sits next to `technicalindicators` or `trading-signals`. So the framing — that the right axis to measure `ta.py` on is pure-Python-against-pure-Python — is a posture, not a benchmarked claim against a named field. What the bench in the repo actually measures `ta.py` against is the NumPy/C-backed field the framing already concedes it expects to lose to, plus an internal naïve textbook baseline that the sliding-window rewrites are meant to beat. The bench methodology and pinned competitors live at [bench/README.md](https://github.com/Bitvested/ta.py/blob/main/bench/README.md); per-run result tables are intentionally not committed, because absolute numbers do not generalise across machines and the bench's own README is explicit about that. I did not start the Python bench from a principled "compare only against pure-Python libraries" stance — I expected `ta.py` to be competitive against everything in the ecosystem, got the C-bound numbers, and only then walked back to the framing above. If you need maximum speed in Python and you are willing to take a C dependency, the C-bound libraries are still the answer. `ta.py` is the answer when you cannot or will not. `ta.go` followed the same pattern as `ta.js` — pure Go, compared against the pure-Go alternatives in the ecosystem, no FFI shenanigans. The comparison set was `markcheno/go-talib` (pinned to a January 2025 build, since there is no semver tag for it) and `cinar/indicator` 1.3.0. Both are pure-Go — `go-talib`, despite its name, is a Go port of TA-Lib rather than a CGO wrapper, which is why the bench runs cleanly with `CGO_ENABLED=0`. The bench lives in a separate `go.mod` inside the repo so those benchmark deps never leak into anyone's `go.sum` when they install `ta.go` itself; the published table is at [bench/BENCHMARKS.md](https://github.com/Bitvested/ta.go/blob/main/bench/BENCHMARKS.md) and the reproduction steps are in [bench/README.md](https://github.com/Bitvested/ta.go/blob/main/bench/README.md). The result was that pure-Go `ta.go` wins or ties every cross-library case at 100k bars. Against `markcheno/go-talib` — the pure-Go port of TA-Lib, which should be the harder comparison of the two — `ta.go` is faster on SMA (1.30×), MACD (1.18×), Bollinger Bands (1.25×), and ATR (1.90×), and within run-to-run noise on EMA and RSI. Against `cinar/indicator` it wins every indicator outright, with the largest gap on RSI at 3.51× and the smallest on Bollinger at 1.34×. The one outlier in the table is the 10k Bollinger row, where `ta.go` is slower than both competitors: `go-talib` skips the rows before the warm-up bar and only emits the post-warm-up series, while `ta.go` and `cinar` both emit the full `n-l+1`-row series. That extra accounting matters more at 10k than at 100k, where the comparison flips and `ta.go` is fastest on Bollinger as well. The row is in the table honestly rather than airbrushed out — it is the `ta.go` equivalent of the Rate of Change gap on `ta.js`, a small constant-factor item I would rather report than hide. ## Three libraries, one release ![1.00](https://media.ninokroesen.com/uploads/2026/05/10655a8149a74f2a04f8/_1280.jpeg) The reason v2.0.0 ships across three repositories at the same version is the same reason the audit and the bench were worth doing in the first place. I have always tried to keep these libraries aligned — what `ta.js` calls `sma` should be the same function `ta.py` calls `sma`, with the same default parameters and the same output shape. Over the last few years they had drifted, and the drift was the kind that compounds: a fix that landed in one repo did not always land in the others, and the longer that went on, the more expensive bringing them back into sync was going to be. So I did the alignment work first, before picking up any of the open user issues that had collected on the individual repositories in the meantime. That is not the most user-responsive call I could have made — there are issues sitting in the trackers that did not make it into v2.0.0 — but the alternative was fixing each issue across three drifting libraries and then having to re-align them all afterwards anyway. Doing the v2.0.0 audit and the v2.0.0 bench work first means that from this point forward, fixing one library and re-aligning the family is a smaller piece of work than aligning all three from a v1-era starting point would have been. A note on what v2.0.0 is not. These are not new libraries. `ta.py` and `ta.go` are existing siblings of `ta.js` that have been part of the family for a while — anyone using one of them has been using the v1-era version of it. v2.0.0 is the version cut that brings them up to a common audited spec, with a common bench methodology, and ships them at the same version number for the first time. It is the first time the three libraries actually agree with each other end-to-end. ## Honest caveats Things I know are imperfect on the v2.0.0 release. Not as a footer; as the part of the post that gives the rest of it any credibility. No outside cross-validation yet. The bench harness ships in each repository, the hardware is specified, the competitor versions are pinned, and anyone who clones the repos can re-run the comparisons themselves. I am hoping that someone does. As of right now, the only person who has run the v2.0.0 numbers is me, and the post is partly a call for that to change. If you run the bench and your numbers disagree with mine — on different hardware, on a different runtime version, with a different competitor version — open an issue. I would rather find out now than after the numbers have sat there as if everyone agreed with them. The Python bench is not the same kind of evidence as the ta.js bench. There is no widely-used pure-Python TA library to put `ta.py` next to the way `ta.js` has pure-JS peers, so the comparison the bench section draws — pure-Python-against-pure-Python — is a posture about what a fair benchmark would even look like, not 66-of-67 against a named field. If that is what you were reading for, the Python bench does not have it. The Rate of Change indicator on `ta.js` is around 22% behind `@debut/indicators` at 100k bars. I shipped it that way rather than holding v2.0.0 for it because the rest of the cross-library wins were already comprehensive and the gap on `roc` is a constant-factor one I can close in a later round. "Wins 66 of 67" reads worse than "wins all of them" but I would rather ship 66 of 67 honestly than dramatise the 67 by keeping the release on the shelf. There are a couple of other indicators across the family in similar shape — small enough margins that the next round of perf work should mop them up. I would rather try to beat them all eventually than pretend I already do. The hand-maintained legacy minified bundle for `ta.js` — the one that predates the `esbuild` pipeline — still ships with the package. This is not a drift footgun the way it might look from the outside. It is a deliberate backwards-compatibility commitment to people who are still pulling that file off a CDN in browser apps that have been running for years. Removing it would silently break those apps, and the cost of keeping the file around is one I am happy to pay. The two `esbuild`-generated bundles are the modern path; the legacy file is the not-breaking-existing-users path. The audit only happened because the v1 test suite was not actually telling me when an indicator was wrong. It is fair to ask why that took as long as it did to surface, and the honest answer is that the suite stayed green and I trusted it for longer than I should have. Some of the indicators it was certifying as correct had been wrong since the first commit on them. That is a kind of mistake you do not catch by looking at the test runner output, because the runner is already telling you everything is fine. You catch it by reading the indicators against the textbook, and that is a separate pass that did not happen until v2.0.0. ## Where it sits now, and what is next `ta.js`, `ta.py`, and `ta.go` are all live at v2.0.0 and in maintenance mode. I am not planning a wave of new functions on any of them right now. What I am planning, over the coming months, is to work through the open issues that have collected on the individual repositories and that did not make it into v2.0.0. The pattern is going to be sequential: fix the library with the most pressing pile of issues, re-align all three so the fixes do not leave the family drifting again, move to the next library, repeat. That is the maintenance shape that I think actually keeps a multi-language family healthy without spending more time on alignment work than on actual fixes. The bench harness ships in each repo and is meant to live there as a forcing function, not just as the artifact behind v2.0.0's claims. Future perf rounds run against it. Future correctness fixes are validated against the same fixtures the audit produced. If the next time someone files an issue is "this indicator gives a different output on this input," the regeneration-from-textbook fixtures and the cross-library bench let me check the report against several other libraries' implementations of the same function. That triangulation is what makes the bench worth carrying as part of the project rather than just running it once for the announcement. If you have been using one of these libraries on its v1-era version, the migration to v2.0.0 is breaking on the formulas the audit fixed. The output of the indicators that had bugs has changed, because the bugs are gone. There is no migration script for that — anyone whose code consumes the v1-era output of those indicators has to look at whether their code was depending on the right answer or the wrong one. The release notes in each repo carry the list. If your usage was on indicators the audit did not flag, v2.0.0 is a drop-in. What I would actually like out of this announcement is for someone other than me to run the bench. The numbers are reproducible. The hardware is specified. The competitor versions are pinned. If you maintain or use a TA library in any of these three languages and you care about the comparison being fair, the bench is already in the repository and it takes one command to run. Whatever you find — agreeing with me, disagreeing with me, or surfacing something I missed — is the input I most want for v2.0.x. I am not trying to be the only person whose numbers are on the record for this comparison. I am trying to be the first. --- title: "Two weeks of agentic coding into a paywalled SaaS category" date: 2026-05-06T18:18:00.000Z updated: 2026-05-06T18:18:49.788Z canonical: https://ninokroesen.com/blog/two-weeks-of-agentic-coding-into-a-paywalled-saas-category lang: en author: "Nino Kroesen" --- # Two weeks of agentic coding into a paywalled SaaS category [Hourly](/hourly) went live on the Mac App Store today, 6 May 2026, alongside the direct download from this site. It is free, runs entirely on your Mac, does not ask for an account, and does not have a paywall behind any of its features. I built it in roughly two weeks. I had not shipped anything to an Apple platform in eight years before this one. The bet I am asking the launch to test is simple. Every incumbent in the menu-bar time-tracking category — WorkingHours, Toggl, Timery — sits behind a paywall of some shape. The reason a category like this one is defensible today is not that the software is hard to write; it is that the cost of writing and maintaining it is high enough that whoever holds it can charge for it. Generative-AI tooling has changed that cost structure faster than the SaaS business models on top of those categories have been able to react. Hourly is the cheapest empirical test I could run on whether that change has actually arrived for a small Mac app, or whether it is still further off than it looks from the inside. I wrote about this framing in [the introduction post](/blog/introduction) when the project was still on a roadmap. This is the empirical follow-up to that. ## The shape Hourly is a menu-bar Mac time tracker. You start a timer for a project, the seconds tick in the menu bar, you stop it. Past entries are listed and editable. Date-range filters and CSV export are there. Billing is multi-currency. There is a Pomodoro mode with break overlays and configurable work reminders. Calendar export to Google Calendar and Apple Calendar. Five UI languages — English, Dutch, German, Spanish, French. Launch-at-login. Storage is local. Everything sits in a single JSON file under `~/Library/Application Support/Hourly/`. No account, no sync, no server side, nothing leaves your Mac unless you explicitly export it. Zero runtime dependencies in the build itself; the only frameworks called are Apple's own. That feature surface is not an accident, and the next section is about how it got chosen. ## How the feature surface got chosen Before scoping the build I went through what was actually behind the paywalls in the comparable apps on the Mac App Store. The Pomodoro mode, the break overlays, the work-reminder pings, the calendar export — these are all features I saw being used as premium-tier carrots in the paid apps. They are not features I would have prioritised on a vibes-driven feature list of "what does a time tracker need." They are features I scoped in because the probe needs to be a fair test. "Free time tracker that does less than the paid ones" is a different bet than "free time tracker that matches the paid ones on feature surface." The bet I want to actually run is the second one. That research is also the reason the app is small. Latest packaged build is 5.4 MB on disk. The competitor sizes I checked — WorkingHours at 18.1 MB, Toggl at 16.2 MB, Timery at 98 MB — sit higher. Some of that gap is because the competitors carry their account/sync infrastructure on the client side and Hourly does not. Some of it is that there are no third-party Swift dependencies in the build. Either way, the size is part of the lean-build bet, and it is one of the things I will be watching once installs start showing up in the App Store reports. ## How I got to a Mac build without Xcode This is the part of the build I did not have a prior on going in. I work on a MacBook Air, I am short on disk space at the moment, and the prospect of installing the full Xcode bundle to compile a small Swift app was unattractive. I would have done it if I had to, but I would not have liked it. The agentic coding tools I leaned on during the build proposed an alternative I did not know was viable: a Swift Package plus a few hundred lines of shell scripts, with only the Command Line Tools installed. No Xcode. No simulator. No Interface Builder. `swift build` and `swift test` from the terminal, the rest is shell wiring up `codesign` and `notarytool`. The dev loop runs out of a text editor. It took some debugging to make work. Apple's tooling assumes you are inside Xcode in a few places it does not document, and the test target had to pin the Command Line Tools framework path explicitly so `swift test` could find XCTest at all. But once it was wired up, the whole thing — build, test, sign, notarize, package — runs end-to-end from the terminal on a machine that has never had Xcode on it. That toolchain is the thing I will carry forward to any future Mac project I start. For a memory-constrained laptop, it was the unlock that made building this app on this hardware tractable in the first place. I would not have arrived at that toolchain on my own. I would have grumbled and installed Xcode. The fact that an agentic coding tool surfaced the no-Xcode flow as a first-class option is the kind of thing that does not show up in the headline benchmarks of "did the model write the code right" but does show up in the actual productivity of a build like this. ## The dual-ship was a mid-build decision The original plan was direct-download only. No Apple Developer account, no $99 a year, no App Store review process. I was going to notarize through Developer ID, host the `.zip` on this site, and call it done. I changed that mid-build. The thesis the launch is meant to test is whether a free local app can move the needle in a category where the incumbents live on the Mac App Store. Avoiding the App Store would have meant the comparison was happening on a surface where the competitors were not. So I paid the $99, registered for an Apple Developer account, set up the App Store Connect side, and added a second packaging script alongside the existing direct-download one. Same source, two output paths. The repository now ships from the same Swift Package to both surfaces. The direct download stays in the launch, and not as a dev artifact or a fallback. App Store installation requires an Apple ID; direct download does not. There is a real privacy-conscious user — and a user on an account-less machine — for whom that distinction matters. I would have shipped that path either way. The App Store is the addition, not the spine. ## What Apple's review caught Three review rounds before approval. Apple really does put effort into reviewing apps, and I am happy to commend them for that even with the rejections in the picture. The first rejection was a real one. The build I submitted crashed on launch on the reviewer's machine, in a place I had not seen it crash on mine. The smoking gun was in the crash log: ``` _assertionFailure static NSBundle.module.getter AppStrings.bundle(for:) PomodoroNotifier.requestAuthorizationIfNeeded() AppModel.init HourlyApp.init ``` `Bundle.module` is the Swift-package mechanism for accessing a package's resource bundle at runtime. It has a one-time lazy init that fatal-errors if it cannot find the bundle. On a fresh App Store install, it could not find it. On `swift run` locally, it could. The reason was a packaging mismatch between what `swift build -c release` produces and what Apple's signing-and-thinning pipeline accepts: Swift Package Manager produces a nested resource bundle, and Apple's pipeline treats nested resource bundles inconsistently enough that the inner one cannot reliably be loaded back at runtime. Flat layout — what every Mac app has shipped for decades — survives the pipeline untouched. The fix was four lines in the packaging script that flatten the SwiftPM-shaped resource bundle into the app's `Contents/Resources/` before code signing. The companion change is on the read side: the bundle resolver tries the flat layout first now, and falls back to the nested layout only when the app is running under `swift run`. That divergence between dev and ship is the thing I would not have caught locally because the local pipeline is exactly the one that hides the failure. Apple's reviewer caught it because Apple's reviewer runs the ship pipeline. I am glad it crashed on Apple's machine and not on a user's. The second rejection was the funny one. Apple would not let me use the phrases "No paywall" or "No annoying IAP" in the App Store listing's screenshot copy. The platform that monetises through paywalls and in-app purchases will not let you advertise the absence of them on its store. I am writing that as a flat statement because the irony is the joke; I do not need to dress it up. The metadata went back in without that copy, and the rejection itself is roughly the cleanest version of the thesis I could have asked for, played out as a piece of the launch process. The third was a clarification more than a rejection — Apple wanted a justification for a server entitlement the app requested. I wrote it up, sent it back, and that round closed. Total review window was about a week. ## Honest caveats The pre-launch user base is one person. Me. I have been using the app on a freelance project I was tracking billable hours on — needing the tool, looking at the Mac App Store, finding nothing free that did the job, and writing the thing rather than paying the toll. There is no TestFlight cohort, no Hacker News post, no forum thread. The launch is the start of the feedback loop, not the end of one. Plan from here is to send it to a small handful of friends I know will use it for a week and give me real feedback toward the next version. The eight-year gap before this build is real. The last Apple-platform thing I shipped was in a different context entirely. Coming back to it cold and getting to a working menu-bar app in two weeks is not a story I would have been able to tell on a previous iteration of the toolchain. The agentic coding tools I used during the build are doing more of the lifting than would be honest to leave out — particularly on the Apple-specific incantations that block you for a day and a half each when you are out of practice. I wrote about that more generally in [the adoption-gap post](/blog/the-adoption-gap); Hourly is the concrete instance of it. I am going to be a little embarrassed if Hourly turns out to have a glaring bug a real user finds in the first week. The repository has a test suite, the suite passes, but a one-user pre-launch is exactly the situation where the thing you forgot to test is the thing the second user trips on. I would rather be told than not. The $99-a-year Apple Developer fee is the only recurring cost I am quietly bitter about, and there is no clean way to recover it while keeping the app free. A donation link inside the app itself is unlikely to get past Apple's rules. I will probably leave that one alone and treat it as the cost of the experiment. ## Where it sits now and what comes next ![1.00](https://media.ninokroesen.com/uploads/2026/05/1c52f190dabad05d4ad6/_1280.jpeg) [Hourly](/hourly) is live on [the Mac App Store](https://apps.apple.com/app/hourly-time-billing-tracker/id6762381291) and as a direct download from this site. The bundle ID is `com.ninokroesen.hourly` if you want to verify it is actually the app I am writing about and not someone else's listing. What I am going to do next is wait and watch. The two branches I have already drawn for myself: * If installs through the App Store materially move — meaning, a free no-account local app can pull users in a category where the incumbents charge — the thesis lands. In that case I am considering open-sourcing the project. The community would be welcome to contribute, the app is genuinely bare-bones at the moment, and an open repo is the natural shape for a tool like this. There is real cleanup work between the current working copy and a publishable repository, and that gates how soon it could happen. * If installs flatline — meaning, the convenience of an account-backed paid app with sync and a polished marketing site outweighs the price advantage of a free local one — the thesis does not land for this category. In that case I would not put the additional work into open-sourcing. The app would stay hosted on this site for the people who find it and quietly run on the App Store until either Apple or I deprecate it. No active write-off; the artifact just sits. If you are a Mac user who tracks billable hours, the install is one click from [the project page](/hourly). If you find a bug or want a feature the app does not have, [the support page](/hourly/support) is the route. Friends I know are going to pull it down for me will not be the only test set; readers of this post are very much part of the test set the launch is here to gather. ## The part that would generalise The toolchain bet is the one I would carry into any future Mac project I start. Swift Package + Command Line Tools + a few hundred lines of shell — no Xcode in the loop unless I genuinely want a SwiftUI preview or a simulator run, and most of the time I do not. The whole project is text-files and version-controllable from the start, the dev loop sits in whatever editor I happen to be using, and the build pipeline is auditable end-to-end without leaving the terminal. The cost is that you do not get the few things Xcode is genuinely good at, and you find out about Apple's hidden Xcode-assumes-itself dependencies the hard way. But once those are dealt with, the result is a Mac build that fits the same shape as a build for anything else. The other thing I am taking forward is less a toolchain and more a discipline. When you are testing a thesis empirically, the cost of running the test honestly is what determines whether you actually run it. If Hourly had needed to ship as a polished SaaS to test the SaaS-vs-free-local question, I would have rolled my eyes and not run the test, and the thesis would have stayed an opinion. Two weeks of build time, $99 in fees, no marketing, no growth team — that is small enough that I actually pulled the trigger. Most of my opinions are still opinions because I have not figured out a cheap enough version of the test for them. The ones that turn into projects are the ones I have. --- title: "Magento's _cl tables had 902 million rows" date: 2026-04-26T15:30:00.000Z updated: 2026-04-27T20:35:57.759Z canonical: https://ninokroesen.com/blog/magentos-_cl-tables-had-902-million-rows lang: en author: "Nino Kroesen" --- # Magento's _cl tables had 902 million rows For more than six months the WorkWear4All storefront went offline every night for a few minutes. I knew it was happening. The agency that built the platform knew. Our hosting provider knew. Three external Magento integrators we asked at various points over that stretch knew. None of them could tell me why, and I could not justify the time to keep digging — there was always something more visible on the queue, the outage was small enough to absorb, and the partial fix we already had in place was just stable enough to remove the urgency. The actual cause was a query that took thirty seconds to write against `information_schema`. I should have run it the first month. Magento 2 is not built for a catalog of the size we run. At 800K+ products, with every product touched daily for stock and price synchronisation from suppliers, the indexer cannot drain the `_cl` changelog tables faster than the platform writes to them, and the storefront eventually pays for that mismatch — in our case with a brief outage every night when something in the indexing pipeline gave up, restarted, and caught back up. The platform itself does not signal that this is happening. There is no warning in the admin UI, no indexer status that flips red before the symptom shows up at the storefront, no log line that points at the root cause. And the fix — once you find it — is documented in at least three public places, none of which surface when you ask a Magento expert to look at the symptom. That is the part of the story I want to write up. Not "I found a clever fix" — the fix is well-known if you go looking from the operation side. The thing worth naming is that the answer exists, no expert I asked could retrieve it, and that is a failure mode of the platform's ecosystem worth writing about honestly. ## The shape of the install I have written about the WorkWear4All stack in the introduction post on this site — multi-store Magento 2 across half a dozen European domains (workwear4all.nl, workwear4all.be, workwear4all.de, workwear4all.com, workwear4all.pl and several more specialised sub-stores), a shared backend running on Apache + Varnish + PHP-FPM + Redis. The piece that matters for this story is the catalog scale and the update pattern. The catalog is 800K+ products, a mix of simple and configurable, sourced through suppliers' daily exports. Every product gets touched once a day for stock and price sync from the supplier feeds. That is at least 800K product updates per day at the source level, and because Magento's catalog indexers are subscribed to product changes — price, category-product, catalogsearch\_fulltext, stock, the list goes on — each of those source updates fans out into a `_cl` row for every subscribed indexer. The per-day write volume into the changelog tables runs into the millions. The most-pinned indexer was always `catalogsearch_fulltext`. It was the one that fell behind first, fell behind hardest, and was at the top of the indexer status whenever something went wrong. We are on Magento 2.4.7, which is the minor release immediately before the 2.4.8 default flip to Update by Schedule — so the schedule-mode behaviour described below is something we opted into, not something we inherited as a default. ## What we tried first, and why the partial fix was the worst possible outcome Before the actual fix, we tried the obvious thing. We installed [Advanced Index Manager by Magefine](https://magefine.com/magefine/advanced-index-manager), which batches the indexing process so it does not try to chew through the entire changelog in a single tick. It made the indexer more stable. The outages got shorter and less frequent. They did not stop entirely. That partial fix is the moment I want to flag. Once the storefront was up enough to stop being a daily fire, the pressure to find the actual cause lifted. The Magefine module bought us six months of "stable enough that the outage isn't blocking anything urgent" — which sounds like a good outcome and is in retrospect the worst possible outcome, because removing the urgency removes the time-budget for the deeper investigation that would have actually solved the problem. We did not stop trying. It just kept losing to the next fire. I knew, six months in, that I would not find this quickly. I had been working with Magento 2 for about that long, and I knew enough to know that diagnosing something this far down the indexer pipeline was going to take significant time I did not have. The agency that built the platform had not found it. Our hosting provider, who has Magento experience of their own, had not found it. The three external integrators we wrote to over that stretch had not found it either. The conversations all ended the same way: someone took a look, could not put their finger on it, and moved on. Looking back at it now, the diagnostic was just looking hard at the database for an anomaly. I am still not sure why nobody — including me, for six months — did that. ## What was actually happening The fix is operationally simple once you see it. Magento 2's "Update by Schedule" indexing mode is built around materialised view changelogs. Every indexer subscribes to a set of source tables; whenever a row in a source table changes, an entry is written to a corresponding `_cl` table for each subscribed indexer. The indexer cron reads new entries from each `_cl` table on a tick, processes them, and clears them. The whole thing is supposed to keep itself in equilibrium: writes flow into the changelog, the cron drains them, the indexes stay fresh. The model assumes the cron can keep up. With 800K+ products updated daily, against the half-dozen indexers that subscribe to product changes, the per-day write volume into the changelog tables runs into the millions. The indexer cron, which processes entries sequentially through PHP, cannot drain that fast on the hardware we have it sitting on. The tables grow. They keep growing. Every day's catalog import adds another layer of backlog the cron will not catch up on, because tomorrow's import is already coming. What happens when a `_cl` table grows past whatever specific threshold its corresponding indexer can no longer cope with — too much memory consumed by the in-flight batch, a query timeout, a lock that never releases, an indexer process that the OOM killer takes out — is that the indexer eventually fails, restarts, and tries to catch up from where it left off. While that catch-up is happening the storefront is briefly inconsistent or unresponsive, depending on which indexer fell over. In our case the most-pinned was always `catalogsearch_fulltext`, which is also the indexer where a stall is most visible to a customer because it gates the storefront's product search. The diagnostic I eventually ran was a single query against `information_schema` listing every `_cl` table by row count, sorted descending. The largest changelog tables came back at around 902 million rows — a number from memory, since I did not save the original output and the tables have long since been truncated. The shape was unmistakable: the indexer cron was not draining; it was being lapped, every day, by the next day's catalog import. ## The cron, and what it does and does not fix The standard `index` cron group is not running on this server. The schedule-mode incremental processing it would do — reading from `_cl` tables, draining them, clearing them — is the thing that does not keep up with our update volume in the first place; the cron group has been disabled, and a nightly full reindex from source is doing the work that the incremental drain was supposed to do. That nightly run is the cron I want to walk through. It performs three operations, in this order: 1. **Full reindex** — `bin/magento indexer:reindex` rebuilds every index directly from the source tables, bypassing the changelog mechanism entirely. 2. **Truncate every** **`_cl`** **table** — once the reindex has produced authoritative indexes, the accumulated changelog entries are obsolete and the space they were taking up is dead weight. 3. **Reset every** **`mview_state`** **cursor** — `UPDATE mview_state SET version_id = 0` for every row. This is the step Max Chadwick's warning post is specifically about; without it, post-truncate changelog tracking silently breaks. The order matters. Doing the reindex first means that if the reindex itself fails — out-of-memory, lock contention, a query timeout — the accumulated changelog entries are still in place and the schedule-mode mechanism (such as it works) still has its baseline state to fall back on. If we truncated first and the reindex failed, we would be left with empty changelog tables and indexes in the broken pre-cron state, which is strictly worse than where we started. Sequencing the destructive operations after the reindex turns a partial failure into a silent no-op rather than an outage. Stripped of the internal wrapper I run it through, the operations look like this: ```bash #!/usr/bin/env bash set -euo pipefail # Run from the Magento root, with DB credentials configured (e.g. via # ~/.my.cnf or environment) so the mysql calls below pick them up. # 1. Full reindex — rebuild every index directly from source. php bin/magento indexer:reindex # 2. Truncate every changelog table; the freshly rebuilt indexes have # made the accumulated changelog entries irrelevant. TABLES=$(mysql -N -e " SELECT TABLE_NAME FROM information_schema.tables WHERE table_schema = DATABASE() AND TABLE_NAME REGEXP '_cl\$'; ") for t in $TABLES; do mysql -e "TRUNCATE TABLE \`$t\`;" done # 3. Reset every mview_state cursor to zero. This is the step Chadwick's # warning is about — without it, post-truncate changelog tracking # silently breaks. mysql -e "UPDATE mview_state SET version_id = 0;" ``` This runs nightly. The storefront has been stable since it rolled out, and even after about a week of normal operation the largest `_cl` table sits at around 9 million rows — roughly a hundredth of the 902 million we found at the moment of discovery. A few side effects also cleared up around the same period — most notably a recurring problem we were having with price updates not propagating through to the storefront, where the price indexer would silently fall behind and customers would see stale prices. That stopped happening too. I cannot fully attribute it to the cron — it might have been the original schedule-mode mechanism falling behind, it might have been related to the Chadwick failure mode before the `version_id` reset was in place, it might have been an unrelated coincidence with a server or SQL restart — but it has not come back since the current setup has been running. I want to be honest about what this fix is. It is not a root-cause fix. The root cause is that Magento 2's indexer architecture was not designed for a catalog this large with this update pattern. The cron buys time by treating each night as a clean slate; the underlying mismatch between the platform's indexing model and our operational reality is unchanged. If the catalog continues to grow or the daily update volume goes up further, this stops working too. The cron is a workaround. There is also a real warning to read alongside this fix. Max Chadwick has [a post titled "Don't TRUNCATE the \_cl tables"](https://maxchadwick.xyz/blog/magento-enterprise-index-lesson-329) that argues — correctly — that naively truncating the changelog tables without resetting `version_id` in `mview_state` puts the install in a state where changelog-based indexing silently breaks, manifesting as updates that never reach the storefront. The cron above does the `version_id` reset explicitly, as step three; the reset is the part that closes Chadwick's specific failure mode. A reader copying the truncate step without the reset is exposing themselves to the exact bug Chadwick is warning about. There is one operational tradeoff worth being explicit about, beyond the "it is a workaround" framing above. With the standard incremental indexer cron group disabled and a nightly full reindex doing all the work, indexes are not being kept fresh during the day — they catch up overnight. That means there is a known staleness window of up to twenty-four hours for any catalog change made between reindex runs. For our update pattern, where supplier feeds run on a daily cadence and the storefront's tolerance for slightly-stale category and search results is generous, that is an acceptable cost. For a shop where the catalog needs to reflect changes in minutes — flash sales, time-limited inventory, anything where intra-day freshness matters — this setup would not work; you would need either to keep the incremental cron group running and find some other way to handle the changelog volume, or rebuild more often than once a night. The honest take: I am still unsure if this is the exact right way to go about it. It works and uptime improved drastically. That sentence is the real edge of my understanding, and the post is more useful with it on the page than without it. ## Why none of the experts found it I want to come back to the social part of this story, because it is the part that generalises beyond this specific bug. Five separate parties looked at the problem before I solved it. The agency that built the platform. Our hosting provider. Three external Magento integrators. None of them proposed the changelog-table angle. The fix is not obscure — when I went back and checked after the fact, it surfaces in the first page of search results for "magento truncate \_cl tables." Max Chadwick wrote about it (warning against it, actually). The mgt-commerce knowledge base has [a step-by-step tutorial](https://www.mgt-commerce.com/tutorial/magento-2-index-suspended/) that walks through almost exactly the cron I built. The Magento GitHub repository has [an open issue (#29789)](https://github.com/magento/magento2/issues/29789) acknowledging the problem upstream. There is even [a Packagist module](https://packagist.org/packages/aligent/magento2-indexer-fix) that wraps the fix into an extension. The fix is documented. It is just unreachable from the symptom side. Search for "magento storefront down at night" or "magento intermittent outage" or "magento indexer stuck" and the changelog-table angle does not surface. The expert you ask for help cannot get there from your symptom either, because their mental model starts from the same set of search terms yours does. The retrievability collapses at the symptom layer, not at the documentation layer. The answer is sitting in plain sight under a search term you have not thought to try, and the experts have not thought to try it for you because it is not the term they would associate with what you described. The lesson I take from this — and the one I would have done differently if I could replay the six months — is to spend less time asking experts and more time looking at what the platform actually has in front of it. The query against `information_schema` was thirty seconds of work. The Magefine module, which I installed instead, was a real expense and a partial fix that bought six months of complacency. Looking at the database first would have saved most of the elapsed time. This is going to get more common, not less. Magento 2.4.8 ships with a default change that flips all new indexers to Update by Schedule out of the box, which is the mode where this whole pattern bites. More shops are about to live a version of this story. ## Where this sits now The cron is the operating mode. There is no migration timeline. The storefront has been stable, the indexer has been keeping up, and the fix is doing its job. Long-term, I think the platform is the wrong fit for our use case and we are going to design something else around it. The shape I am thinking about is an A/B-style cutover — start the new system on one of the smaller storefronts, prove it under live traffic, and only then think about migrating the larger ones. Our internal tooling already has the catalog data sitting in a MongoDB backing store, so the data layer for the new system is in place; the storefront and the surrounding operational pieces are not. There are no firm plans yet. The specifics of the replacement architecture are not something I am ready to put on the record. What I will say publicly is what I have learned to design out: an indexer pattern that materialises a generic flexibility-first data model into a set of denormalised indexes through a cron that has to drain a changelog. That entire shape — the EAV-to-indexer pipeline that defines Magento 2's catalog architecture — is the thing the cron in this post is patching around, and it is the thing I do not want to inherit into anything new. What I will design *in* is a separate post once there is something running to point at, not before. I would rather write up the second attempt once it has survived contact with reality than commit to specifics in advance. ## The part I keep coming back to The principle I keep coming back to on this is that a partial fix that removes the urgency is worse than no fix at all. The Magefine module was a real piece of engineering, it did real work, and it made the storefront more stable than it had been. It also bought us six months of "this is fine," and during those six months I stopped looking at the actual problem because the actual problem had stopped costing me sleep. The next fire always wins against the smouldering one, and the partial fix is what turns the fire into a smoulder. I do not have a clean rule that prevents this in the next system. The closest I can get is: when something has been tolerable for months but you have not actually understood what it is, treat the tolerance itself as the warning sign. The cron in this post should have existed five months earlier than it did. The reason it did not is that nothing was on fire enough for me to make it. --- title: "A hundred technical indicator functions in JavaScript" date: 2026-04-26T15:10:00.000Z updated: 2026-04-27T19:53:41.217Z canonical: https://ninokroesen.com/blog/a-hundred-technical-indicator-functions-in-javascript lang: en author: "Nino Kroesen" --- # A hundred technical indicator functions in JavaScript My first open-source project is a technical-analysis library called `ta.js` — around a hundred indicator functions in JavaScript, first commit on 2020-08-24, v1.17.0 shipped earlier this week. I started it because I needed the indicator functions for my own work anyway and writing each of them turned out to be the cleanest way to learn them. The first three days' commits added Money Flow Index, Rate of Change, and On-Balance Volume; the RSI fix landed four days later; Stochastics two weeks in. The library was being built in parallel with me working through the textbook. I published it because if anyone actually used it, they might open issues on the ones I had gotten wrong, and that scrutiny loop was cheaper than anything I could have built for myself. The honest read of the thing five and a half years later: I built my first open-source project in JavaScript because I didn't yet know how much execution speed matters in quantitative systems — the language turned out to be wrong for the backtest and right for the half of the work where I was still figuring out what I was building. That tradeoff is not something I picked on purpose at the start. It is something I learned later, once I had tried to run backtests at non-trivial size and watched them take long enough that the only honest response was to move the production-shaped parts of the work to a different stack. The library stayed where it was still earning its place, which was everything around the backtest — loading data, slicing it, plotting an indicator against a window of OHLC in a browser tab, iterating on a formula until it matched what the textbook said it should. ## The shape A hundred-ish standalone pure functions — moving averages, oscillators, bands, volatility indicators, statistics, chart transforms, a PRNG — each in its own file, each short enough to read in one sitting. The smallest moving-average implementation is 9 lines of JavaScript. RSI is 12. The whole library has one devDependency (`esbuild`, for the bundles) and zero runtime dependencies. It distributes three ways: the npm package, an IIFE bundle hosted at unpkg that you can drop into an HTML page with a `