Private Space ← Overview Pilot demo Source on GitHub ↗

Solo build log · 20 Jun – 2 Sep 2026 · 75 days

Private Spacefrom Notion clone to a workspace that runs itself

A local-first, self-hosted Notion alternative that grew, over eleven weeks, into a project-management system piloted by a local language model — with a parallel CI suite that reports its own results back onto the app's boards. 269 commits, 53 merged pull requests, and one rule that never bent: nothing merges without evidence.

269commits on main
53merged PRs
695Cypress tests, 0 failing
117spec files
63backend modules
27klines of app code
12parallel CI suites
6security audit rounds

Cadence and coverage, day by day

Bursts, not a steady drip — and the test suite never went backwards.

commits per day cumulative spec files 4 Jul — E2E harness lands 29 Aug — suite moves to CI

01 20 – 24 Jun
Commits
3
Backend files
0 → 17
Front end
2,380 lines

A working product first, then take it apart

v2.1.0 shipped as a complete app on day one. Four days later it was pulled apart into modules before a single new feature was added.

The starting point was already real software: Express, better-sqlite3 in WAL mode, a vanilla-JS single-page editor, and a Docker volume holding the entire workspace — nested pages, rich blocks, databases with table/board/calendar/gallery views, trash with cascade restore, JSON export/import, HTTP Basic Auth with constant-time comparison, and an activity feed.

The first substantial commit after that shipped no user-visible feature at all. It moved a single server file into seventeen modules under src/ and added account management, an on-device meeting AI, push reminders, and security hardening on top of the new shape. That ordering is the whole thesis of the project: the structure gets fixed while it is cheap to fix.

Constraint accepted early

One server, one web codebase; every package is a thin shell around it. Written down in PACKAGING.md alongside the constraint that kills the tempting alternative — watchOS has no browser and no third-party WebView, so a PWA cannot run on an Apple Watch. The watch talks to the server through Shortcuts, Calendar and notifications instead. A documented "we don't do that, and here's why" is cheaper than a fork that rots.

02 2 – 5 Jul
Commits
56 in 4 days
Peak day
21 (5 Jul)
Front end
2.7k → 4.6k lines
Specs written
0 → 42

Four days of parity, and the decision that outlived them

Filters, sorts, relations, rollups, formulas, dark mode, backlinks, templates, graph view, time tracking, a subscribable calendar feed. Then a test harness — and the sprint's real output turned out to be the harness.

The database engine landed in roughly this order: filters and sorts, multi-select/URL/email property types, relation and rollup columns that link databases and aggregate across them, then a formula property with a guarded expression evaluator and table grouping. Around it: [[page]] mentions with a picker and a backlinks section, favourites, recurring tasks, gradient covers, an inline formatting toolbar, multi-column layouts, a quick switcher on Ctrl+P.

On 4 July an isolated Cypress environment appeared — a second container on port 3001 with its own throwaway volume, seeded credentials and schedulers disabled. Every feature commit from that day forward carries (+cypress) in its subject line. It is visible in the chart above as the moment the lower plot leaves zero and never returns.

The following day produced 21 commits: CSV import and export, page version history with restore, bulk row operations, a row detail modal, deep page duplication, footer aggregations, two-way relation sync, per-view column visibility, a command palette, a timeline view. Each one arrived with the spec that proves it.

What changed, in one line

Before 4 July the question after a change was "does it look right?". After it, the question was "which spec would have caught this?" — and that question is answerable at 3 a.m. by someone who has forgotten how the feature works.

03 8 – 20 Jul
PRs
#1 – #6
Audit rounds
6
Suite
121 → 222 tests
Backlog state
emptied ×6

Six rounds of attacking my own work

The codebase was retraced into a written backlog, then audited six times in numbered rounds. Every finding shipped with a regression spec named after the round that found it.

The method was deliberate: stop building, read the code as an adversary, write down every unfinished edge and every unsafe path in BACKLOG.md, then work it top-to-bottom. Rounds two through six each opened after a merge, on the theory that new code is where new holes are.

  • An unscoped guard locked everyone out. router.use(requireAuth, requireAdmin) sat on a router mounted at /api, so every route mounted after it — tasks, push, meetings, space, pages — became admin-only. Regular users got 403 across the entire application.
  • /uploads was unauthenticated. Random filenames were being used as security, which makes every upload URL a permanent capability token. Moved behind requireAuth; in-app images keep working on the session cookie.
  • CSV export was formula-injectable. Cells beginning = + - @ execute on open in Excel and Sheets. Escaped per the OWASP mitigation, while leaving genuine negative numbers alone.
  • Month-end recurrences drifted. setMonth() overflows, so a task due 31 January recurred to 2 March and crept forward every month after.
  • Read-only share links leaked private comments. The public endpoint returned the raw page blob including per-row comments and attachment URLs. Now it returns schema, views and cell values only.
  • Excel's UTF-8 BOM silently ate the first column of every imported CSV — "Task" never matched the header "Task", so the column mapped nowhere and no error was raised.
  • Concurrent edits were last-write-wins. Replaced with optimistic concurrency: the PUT carries the timestamp it loaded, the server 409s a stale write, and the client offers keep-mine or load-theirs rather than silently discarding typing.
  • Vietnamese IME composition was wiped in the chat composer — Enter sent a fragment mid-composition. Fixed, then generalised into a world-language pass: script-aware font stack, bidirectional text, Unicode-safe mentions.

Rounds also recorded what was checked and found healthy: that X-Forwarded-For could not be rotated to bypass the login rate limit, that share tokens are 18 bytes of entropy, that the server-rendered public view escapes markup. An audit that only lists problems can't be distinguished from an audit that stopped early.

04 21 – 27 Jul
PRs
#7 – #16
Audit doc
36 KB, 15 sections
Suite
222 tests green

The design audit, including the parts I got wrong

Contrast ratios, bounding boxes and focus behaviour measured in the live DOM rather than read off the stylesheet. Verdict: the light theme was below accessibility minimums and the app was effectively keyboard-inoperable.

The findings were not aesthetic. Light-theme text failed WCAG AA contrast; things that looked clickable were mouse-only; there was no type or spacing scale, so every new component was a fresh set of ad-hoc decisions. Fixes shipped in phases across ten pull requests: contrast tokens, a visible focus ring, a constrained reading measure, the sidebar page tree rebuilt as a keyboard-navigable ARIA treeview, semantic colour tokens that theme both light and dark from one definition, unsaved edits flushed on tab close, and two conflicting mobile breakpoints collapsed into one.

Correction, published inline

Two findings in the first draft were my own measurement errors, and they are retracted in the document rather than quietly deleted. The browser pane had been running with visibilityState: hidden, which freezes CSS transitions at currentTime: 0 — so getComputedStyle returned pre-transition values and reported a mobile drawer "covering content" that was correctly off-canvas at translateX(-260px) the whole time.

The audit also states what it could not verify: rendered appearance, animation smoothness, real-device behaviour. A measurement is only evidence if its limits are on the record with it.

One finding was deliberately deferred rather than fixed, with the reasoning written down — the document's ragged left edge, which was later straightened in PR #15 once the breakpoint work made it cheap. Deferred-with-a-reason is a different artefact from forgotten.

05 28 Jul – 4 Aug
PRs
#17 – #27
Shape
9 of 11 were fixes
New surfaces
news, materials, music

Features that fetch from the outside world

A news page that fills itself from RSS on a timer, a materials library of openly-licensed audio, video and images, and a transcriber that turns a hummed melody into notation. Nine of the eleven pull requests in this stretch were fixes to the two before them.

That ratio is the honest signature of integration work. The materials library shipped, then needed: deeper paging so the library actually kept filling, browsable counts that stopped lying, thumbnails that loaded, a rule that never prunes a material a page is using, unstuck paging cursors, audio previews, video previews, relevance ranking and collections. Each of those was its own branch, its own spec, its own merge.

Failure mode — trusting the instrument

The music transcriber's test fixture was a synthesised "vibrato" that was, on inspection, a chirp — the fixture was wrong, not the code. It became the third of three near-rewrites of working software caused by believing a bad measurement, alongside the frozen renderer above and a programmatic .focus() that does not match :focus-visible.

The tell was the same all three times: a result too broken to be plausible. That pattern is now a written rule — before believing a measurement, sanity-check the instrument.

Tooling trap, cost: one afternoon

cy.reload() hangs on a window whose prompt was replaced by a stub in onBeforeLoad. The page renders, every resource returns 200, and the load event simply never fires — so the spec silently eats the full page-load timeout and reports as a product failure. The fix is a fresh cy.visit(). It is now the third entry in a written gotchas file, because a trap that costs a day once should not cost a day twice.

06 10 – 19 Aug
PRs
#28 – #34
Models driven
llama3.2:3b, qwen2.5:7b
Backend files
25 → 29

An agent that has to prove what it did

A local language model was given a project board it could create, read, update and work through on a deadline. The entire architecture is built around one assumption: the model will claim it finished.

A board that lies is worse than no board, because you act on it.Design note, PR #28

So the guarantees are structural rather than prompted. Done is reachable only through finish_task, which refuses unless a tool that produces something has actually returned during this run; set_status cannot reach Done at all, or the rule would be one synonym from bypassed. The evidence written onto the card comes from the server's own record of what its tools returned — never from the model's argument, because a 3B model asked for evidence types a plausible page id as readily as the real one. read_page is a work tool that deliberately does not count as evidence: reading is an input, not an outcome. There is no delete tool at all. And "Needs you" exists as a first-class outcome — that status existing is what makes the rest trustworthy.

Scheduling is deadline-driven rather than polled: one timer armed for the earliest deadline on the board, nothing at all when no task carries one, exponential backoff at 10/20/40 minutes, and "Needs you" after four attempts — because a permanently impossible task otherwise re-runs the instant every run ends, which is the most expensive way this could fail.

Contact with a real model — PR #29

The guardrails held: nothing was ever falsely marked Done. But almost nothing completed either, for a reason the scripted-brain tests could not reach. Small models emit tool calls as prose, constantly. llama3.2 created a real page, then wrote finish_task(id="k1", evidence={…}) as text. Read literally, that means the model stopped — so the loop ended with a page on disk and the card still reading Todo. Work done and lost.

Four call forms are now recovered, each observed verbatim in a real run, and three parsing bugs sat behind them: a lazy regex that stopped at the first } — which is the nested arguments object, so it failed on exactly the calls worth recovering; markdown-escaped underscores (call\_tool) making JSON.parse reject an otherwise perfect object, which cost llama3.2 a correct "Needs you" over one character; and escape sequences surviving as two literal characters into the card text.

The limit that prompting cannot fix

The evidence gate proves an artifact exists. It cannot prove the artifact was the right thing to make. Both models wrote a document about a physically impossible task — "sign the lease in person" — and marked it Done.

Two mitigations shipped rather than one wish: the artifact is named in the evidence so the mismatch is visible at a glance, and a review gate (PR #32) makes an agent completion a claim until a person accepts it. The failure is written down as a known limit, not described as solved.

07 24 – 28 Aug
PRs
#35 – #40
Backend files
29 → 46
Managed boards
16
Hardware
RTX 4060 Ti, 8 GB

A fleet, an MCP server, and every number re-measured

One agent became an autopilot across sixteen project boards, with roles, leases for workers on other machines, budgeted routing across providers, and an MCP endpoint exposing it all to external tools. Then the hardware turned out to have been idle the whole time.

The system grew a shape: a page can declare itself a project the pilot manages; each tracker links to a dedicated page; observed local git activity feeds a work log that becomes tickets; a business-analyst role confirms work independently; and every project task routes to a bot under an explicit per-card authorisation, with local Ollama as the private default. A stateless Streamable-HTTP MCP server exposes status, boards, task creation, evidence-aware updates, execution and safe stop — disabled entirely unless a bearer token is configured.

Correction shipped as its own pull request — #40

The Ollama container had never been given --gpus all. Every timing measured before 28 August was CPU inference on a machine with an idle GPU — confirmed by docker inspect returning null for device requests and nvidia-smi being absent inside the container.

The correction was not folded quietly into the next feature. It is a branch named docs/gpu-measurement whose entire purpose is to fix guidance that had been written down wrong.

llama3.2:3b, warm, identical prompt — before and after the flag
MeasurementCPUGPU
Single call3,497 ms~1,000 ms
4 concurrent, wall clock17,453 ms~2,000 ms
Concurrency throughput0.80×1.7 – 2.1×

Two findings from that re-measurement changed the product rather than just the docs. Concurrency against a default Ollama is a pessimisation — 0.80× of running the same calls serially, because it serves one request per model and a queue only adds latency. Agent lanes therefore default to one, and are raised only with OLLAMA_NUM_PARALLEL set to match.

And on an 8 GB card, model choice is decided by residency, not quality. llama3.2:3b occupies 4.0 GB, stays resident, answers in about a second. qwen2.5:7b at 5.5 GB loads fully to the GPU and is then evicted mid-run, giving 28–47 seconds per call — twenty-five times worse — even at a parallelism of one. On every structured test devised for them (tool-call shape, argument correctness, strict JSON, refusing an impossible task) the two scored identically, 3 out of 3. A resident 3B beats an evicted 7B decisively, and no amount of benchmark reading would have said so.

Where the requirement came from

An idle model pins 3.85 GB of the 8 GB card whether or not anything is asking it anything, and the pilot's five-minute cycle re-arms the keep-alive timer forever. Measured while a game was running: 7,581 of 8,188 MiB used, with the game starved to 2,545 MB — it took 4,820 MB once the model was evicted.

That is why the app ships a GPU switch rather than a settings toggle. The first implementation reported the wrong thing: Ollama acknowledges an unload immediately, but its runner takes about twenty seconds to hand the memory back, so the first reading told the user the card was still busy at exactly the moment they were deciding whether to launch a game. It now polls until the memory is really free and reports what it observed — or says it timed out, rather than claiming a freedom it did not see.

08 29 Aug – 2 Sep
PRs
#41 – #53
Peak day
26 commits (30 Aug)
Suite
117 specs / 695 tests
Wall clock
~5 min → ~3 min

CI as infrastructure, and the bug that made it necessary

The suite had only ever run on one laptop, which means green had only ever meant green there. Moving it to GitHub Actions found four defects a green local run had been hiding.

The build splits into one parallel job per functional area — smoke-safety, pages-editor, database-core, planner-calendar, agent-automation and seven more — and only the second reason is about speed. A red job names a broken capability rather than a shard number, and reports to the board the moment it finishes instead of waiting behind the slowest job. The honest caveat is written into the workflow file: twelve runners cost roughly three times the billed minutes of one.

The failure mode of sharding is silence — a spec that matches no group simply stops running while the build stays green — so ci/plan.js refuses to emit a matrix at all unless every spec file belongs to exactly one suite. Every job carries an explicit timeout, sized at several times its measured duration, because the platform default is 360 minutes and one wedged step otherwise burns six hours of runner time and reports nothing.

Four bugs that only exist on a real runner

A dead mount. The fixture generator replaced the very directory bind-mounted into the container, leaving the mount pointing at a dead inode so every write inside failed.

Ownership that Docker Desktop had been hiding. Fixture files belonged to the runner user and were not group-writable, so a container running as uid 1000 got EACCES — permission denied, not read-only. It passed on the laptop every single time because Docker Desktop ignores Unix ownership on bind mounts.

A timezone split. The container defaults to Asia/Ho_Chi_Minh while Cypress runs on the runner; bare deadlines resolve in the server's zone, so a seven-hour offset made "today" read as already past.

An artifact path that moved itself. upload-artifact roots an archive at the least common ancestor of its paths, so adding a console log beside the JSON silently pushed the JSON down a directory — a flat cp then matched nothing and every suite read as crashed. The coverage guard caught it, which is what it is for.

Alongside the CI work, the pilot itself got its sharpest bug report from the person using it: "the AI isn't managing tasks, it's just idle." It was not idle. Sixteen managed boards, 55 overdue tasks, and a run log reading SonicDrop 47 runs · Private space docker 24 · eleven others 1–2.

Earliest-deadline-first was the cause, and the mechanism has to be stated to be believed: the board holding the oldest overdue deadline wins every selection, and working it does not stop it holding the oldest, because a run touches a handful of its 22 overdue tasks and the rest keep their old dates. A board with one overdue task drops out of contention after a single run. Big boards monopolise; small boards starve. Overdue boards are now served least-recently-first, so waiting time is bounded by boards × cycle rather than left to chance.

And the first fix was dead code

The first attempt asked nextWakeAt(board) <= now to find overdue boards. But nextWakeAt ends with Math.max(best, now + MIN_WAKE_MS) and never returns a time in the past — so nothing was ever overdue, the whole branch was unreachable, and it degraded silently to the exact behaviour it was replacing.

The tell was that the tests passed identically before and after the change. A test suite that cannot tell your fix from your bug is telling you something about the test, and that is the note that got written down.

The most expensive bug in the project

A page block renders content and nothing else. Every server-side generator had been writing text instead — so 54 of 72 pages rendered as blank lines while the data sat intact in the database, invisible on screen and invisible to full-text search, which also reads content.

Four Cypress specs asserted on text and stayed green throughout. The fix was not just the field: src/blocks.js is now the single factory every generator must use, and asserting on text in a spec is a written prohibition.

How the work was managed

Eleven weeks, one person, no standups. What held it together was a small number of written rules that never changed, each one adopted the day something went wrong without them.

One ship cycle, 53 times

Branch → push → CI green → pull request with a real write-up → merge → rebuild → confirm the app answers on :3000. No exceptions, including for one-line fixes. evidence — 53 merge commits, every feature branch named for its intent

Commit messages as design documents

The agent's evidence architecture, the fairness bug's mechanism, the GPU re-measurement and its numbers all live in commit bodies, not in a wiki nobody opens. The reasoning ships attached to the code it explains. evidence — PR #28, #29, #45, #46 bodies run 30–50 lines each

A backlog that is a contract, not a wish list

Retraced from the code rather than from memory, worked strictly top-down, and struck off in the same commit that fixes the item — with the reasoning for anything deferred written beside it. evidence — BACKLOG.md, 14 KB, six rounds each closing with "backlog empty"

Traps are written down once

A running file of failures that each cost real time and look like product bugs but are not: a Python write that truncates the front-end file mid-emoji, stale test containers producing walls of fake regressions, control bytes hiding inside a regex literal, a scheduler that reads as broken when a precondition is simply off. evidence — nine entries, each with the symptom, the cause, and the check

Corrections are shipped, not buried

Two retracted findings inside the design audit, and a pull request whose only purpose is to fix a measurement that had been documented wrong. Being visibly correctable is cheaper than being quietly wrong. evidence — DESIGN-AUDIT.md §2 and §5; branch docs/gpu-measurement

Measure before optimising, then believe the measurement

Concurrency turned out to be a 20% loss. A smaller model beat a larger one by 25×. Splitting CI cut wall clock by 40% at 3× the billed minutes. All three are numbers from this machine, and the third is recorded as a trade rather than a win. evidence — timing tables in commit bodies and the workflow's own comments

Node 22 · Express · better-sqlite3 (WAL) Vanilla-JS SPA, 8,812 lines Docker · Cypress 13.17.0 · GitHub Actions Ollama · MCP · web-push 20 Jun – 2 Sep 2026 Drive the pilot → Repository ↗ dajikyguro3m@gmail.com LinkedIn ↗