Orbit
Ideas
- idea Backfill history entries from periodic sync
History
- 08:18 Replace tea with forge in merge instruction
-
forge is the unified git-forge CLI that replaces tea locally.
forge pr mergehits the same Forgejo API endpointtea pr mergedid, so the merge-via-Forgejo-API constraint is preserved.
- 07:44 Show deploy info on project detail pages
-
Projects had no way to show where they're deployed. The project detail page now shows a second meta strip with the deploy URL, SSH hostname, last deploy timestamp, and a short description — when deploy info is configured.
New store fields (deploy_url, deploy_ssh, deploy_description, deployed_at) added via ALTER TABLE migration. An MCP tool (update_project_deploy) sets the info, and POST /api/projects/{slug}/deploy records deploy timestamps for use by deploy scripts.
- 05:57 Update conventions: require tea pr merge for orbit-tracked projects
-
Manual git merge --squash bypasses Forgejo, so orbit never learns a PR was merged and no history entry gets created. The orbit conventions layer now explicitly requires tea pr merge --style squash for all merges.
Applied via conventions apply.sh.
show earlier entries
- 19:54 Wire expandable entry stories into Go templates
-
The design system added expandable entry stories (PR #31) for history, PRs, and issues. This wires the Go side: entryRow gains Body, StoryID, and DesignHref fields, the project handler populates them from issue/PR bodies and PR lookups for history entries, and the template uses a shared entryrow partial that renders expandable rows when a body is present.
CSS and JS in web/static updated to match the design system.
- 19:41 Add expandable entry stories for history, PRs, and issues
-
History entries, pull requests, and issues on the project detail page are single-line rows with no way to see the full description. Clicking an entry now expands it to show the body text below, aligned to the title column.
The approach avoids nesting inside CSS grid (which caused subgrid alignment issues). Instead, expandable rows are plain .entry-row elements with a sibling div for the body. A small JS click handler toggles visibility. The dot pulses on hover as an affordance, matching the home page project rows.
PRs and issues include a "See on code.bas.es" link in the expanded body. History entries show the PR description.
Design system only — no Go changes yet. Implementation will follow.
- 18:05 Deduplicate history_entries before adding unique index
-
Summary
- Fix migration crash on production: existing duplicate
(project_id, pr_number)rows from webhook redelivery preventedCREATE UNIQUE INDEX - Dedup step keeps the oldest row per pair before creating the index
Test plan
- [x] All tests pass (
go test ./...) - [x] Deployed to production — service started successfully, backfilled 3 entries
🤖 Generated with Claude Code
- Fix migration crash on production: existing duplicate
- 18:05 Redirect trailing slash on project URLs
-
The back-link from design system preview pages points to /projects/{slug}/ with a trailing slash. The project route only matches without one, so the link 404s. This adds a redirect from the trailing-slash variant to the canonical URL.
Closes #27
- 18:05 Webhook action probe
-
Throwaway PR to inspect Forgejo merge webhook action
- 18:04 sync: Backfill history entries for merged PRs
-
Summary
- Make the periodic sync resilient by backfilling history entries for merged PRs missed by webhooks (late webhook setup, downtime, network issues)
- Add
UNIQUE(project_id, pr_number)constraint tohistory_entriesso both webhooks and sync are idempotent - New
ListMergedPRsWithoutHistoryquery finds gaps;BackfillHistorysyncer method fills them at startup and every 15 minutes
Closes idea #3
Test plan
- [x]
TestCreateHistoryEntryIdempotent— duplicate insert returns (nil, nil), no error, one row - [x]
TestListMergedPRsWithoutHistory— merged without history returned, merged with history excluded, open excluded - [x]
TestBackfillHistory— end-to-end: sync merged PR, backfill creates entry, second run is idempotent - [x] All existing tests pass (
go test ./...)
🤖 Generated with Claude Code
- 17:19 Add unit tests for projectDotClass
-
The three-state dot logic (muted/active/done) from PR #25 had no unit tests. This adds table-driven tests covering all state transitions: stale projects always muted, active projects with open issues or non-draft PRs show orange, draft-only or no open work shows green.
- 16:49 Add three-state project dots and orbit back-links
-
Project dots on the home page were binary: orange or grey. This doesn't distinguish a project with open work from one that's caught up.
Projects now show three dot states:
- Green (done): active within 7 days, no open issues or PRs
- Orange (active): active within 7 days, has open issues or PRs
- Grey (muted): no activity for over a week
The project detail page also gets a dot next to the project name, using the same logic.
Also adds "back to orbit" links from the styleguide pages and fixes the trailing-slash redirect on the preview handler so relative links resolve correctly. Updates the scaffold templates with matching back-links.
- 14:51 Add orbit back-link to scaffold templates
-
The scaffold templates used by init_design had no way to navigate back to the orbit project page. Added relative "orbit" links to both the styleguide index and the preview breadcrumb so users can get back to the project.
Only affects newly scaffolded projects. Existing projects need a separate update during a session.
- 14:27 Hide design system link when project has no styleguide
-
The project detail page always showed the "design system" link in the header, even for projects with no styleguide directory. Clicking it returned a 404.
The link is now gated on HasDesign, which is true when the project's source can resolve its styleguide directory. Projects without one no longer show a broken link.
- 13:20 Adopt arne/conventions for CLAUDE.md management
-
Orbit's CLAUDE.md was entirely hand-written. The arne/conventions repo now manages shared rules (git workflow, PR/issue conventions, spec flow, styleguide protection) via apply.sh, which writes generated content between markers in CLAUDE.md and places reference docs in docs/conventions/.
This adds a conventions.yaml declaring orbit's layers (orbit, orbit-design, systemd-service), runs apply.sh to generate the managed block, and trims the hand-written section to just deployment and source layout — the only orbit-application-specific content.
Also fixes the design-session hook regex to allow hyphenated layer names like orbit-design/, and registers arne/conventions as a project in orbit.
Closes #21
- 07:44 Use file mtime for design-spec last-activity
-
Design specs in the Design stage always glowed sodium because
designs.Filehad no modification time — the web handler passedActive: trueunconditionally.This adds a
ModTimefield todesigns.File, populated fromos.StatinLocalSourceand from the Forgejo contents API'slast_modifiedfield inForgejoSource. The project handler now computesActiveviaisActive(f.ModTime), the same 7-day window rule used for every other flow item.Closes #6
- 07:28 Stop hard-coding 'arne/' as the owner prefix
-
The home-page template hard-coded
arne/as the owner prefix in every project row. The owner is already available on the web handler viaORBIT_FORGEJO_OWNER, so this threads it through to the template via a small view struct.Closes #8
- 20:56 Document issue title and description conventions
-
PR #14 settled how pull request titles and descriptions should read, but issues were left unexamined. The current open backlog makes the gap concrete: every one of the four open issues uses an area prefix (
design-system:,web:,project-detail:) followed by a telegraphic description that barely stands on its own as a sentence. That's exactly the pattern we moved away from for PRs, and for the same reason — the prefix is tagging noise a human reading the title doesn't need, and the Forgejo label system already handles area categorization better.This PR extends
CLAUDE.mdwith anIssue titles and descriptionssubsection that applies the same discipline (active voice, plain English, no area prefixes) with one deliberate difference: tense. PR titles describe work done, issues describe work needed. The doc also calls out that issues are seeds, not specs — prose over bullets, aScopesection to bound what closing the issue looks like, and no pretense of being a specification.Follow-ups
- Rewrite the four open orbit issues (and the single closed one, #7) to match the new convention. This is the parallel of the historical-PR rewrite that followed the PR convention doc.
- Add a history entry recording the issue rewrite once it's done.
- 20:25 Remove the webhook action probe artifact
-
While finishing up PR #15 (the merge-history-on-edit bug fix) a follow-up question came up: what action string does Forgejo actually fire for a merge webhook? The test in PR #15 assumes
"closed", matching GitHub convention, but the only way to be confident was to observe it directly.To observe it, a throwaway PR #16 with an empty
.webhook-probefile was opened and merged. The DEBUG log added temporarily tohandlePREventconfirmed Forgejo firesaction="closed" merged=trueon merge — so the guard in PR #15 is correct. But merging #16 landed the empty.webhook-probefile on main as a side effect.This PR removes the file. One line of diff, no code changes, no test changes. The diagnostic logging was never committed.
Why PR #15's merge didn't create a history entry
Worth recording this since it explains a visible gap in orbit's history stage. PR #15 was merged at 20:18:16, and the deploy sequence that followed (
go build && install && systemctl restart) restarted orbit's systemd service at 20:18:17 — almost exactly when Forgejo was trying to deliver the merge webhook. The delivery landed in the ~1-second restart window and was either refused by Caddy or silently dropped; Forgejo did not retry successfully.The PR row for #15 exists in the
pull_requeststable because the periodic sync running on startup backfilled it from Forgejo's API. But the periodic sync only upserts PR rows — it does not create history entries. History entries are webhook-only, and the webhook was lost. The history entry for PR #15 will be inserted manually via SQL as part of this cleanup.Lesson: avoid restarting orbit immediately after merging a PR that it webhooks into. Separate the operations by at least a few seconds so the webhook has time to land.
- 20:18 Only create merge history entries on the actual merge event
-
The PR dispatch in
handleWebhookused to firehandlePRMergewheneverpr.Mergedwas true, without checking the webhook action. A merged PR staysmerged=trueforever, so any subsequent webhook event on it — a title change, a body edit, a label tweak, a comment — would re-fire the handler and insert another history row for the same merge.We noticed this while rewriting historical PR titles via the Forgejo API: each PATCH to a merged PR triggered an
editedwebhook, which produced a duplicate history entry. Seven PRs ended up with fourteen spurious rows in orbit's live DB before the pattern was clear. The duplicates were cleaned up manually via SQL, but the underlying bug remained.The fix
One-line guard on the dispatch. Require
action == "closed"alongsidepr.Merged, so the handler runs only on the actual merge event:if payload.Action == "closed" && payload.PullRequest.Merged { s.handlePRMerge(project, payload.PullRequest) }The PR row itself still updates via
handlePREventon every webhook delivery — that runs unconditionally and is idempotent by design, so PR metadata in the store stays in sync with Forgejo regardless of which action fired.Regression test
TestWebhookPR_EditedAfterMergeinapi/webhooks_test.goexercises the bug directly: merge a PR, assert one history entry exists, send a follow-upeditedwebhook on the same (still-merged) PR, assert the history entry count is still one. The test also verifies the PR row picked up the edit viahandlePREvent— so we're confirming the fix doesn't over-correct and suppress legitimate state updates.Follow-ups
None. The fix is surgical and the test covers the exact scenario that produced the duplicates.
- 20:11 Rewrite historical PR titles and descriptions to follow the new convention
- 19:53 Document PR title and description conventions
-
The repo's git conventions in
CLAUDE.mdencouraged conventional-commit prefixes (feat:,fix:,chore:) on PR titles, but as orbit has grown those prefixes have started reading as clutter — they add technical noise without telling a reader anything about what actually changed. The History stage on orbit itself makes this concrete: a row that saysfeat: pull request tracking and Claude removalis worse to scan than one that saysShow open pull requests in the Review stage.This PR drops the prefix guidance and adds a short section covering title and description structure. Titles should describe what orbit can do now, active voice, plain English. Descriptions should open with the problem, use subheadings for bundled concerns, and keep scope-boundary notes (
Known cuts,Follow-ups) at the bottom.No code changes, no behavior changes. Documentation-only, so the convention is in the repo before the backlog of older PRs gets rewritten by hand to match.
Follow-ups
- Rewrite titles and descriptions on the previously-merged PRs
(
Orbit MVPthrough #13) to serve as in-repo examples of the new convention. - Normalize commit author email on the three commits made from local
git config (
arnefismen@gmail.com→arne@fismen.net) during the rewrite. - Consider a semantic taxonomy later (labels, History stage categories) once a few more PRs have landed under the new convention.
- Rewrite titles and descriptions on the previously-merged PRs
(
- 18:24 Show open pull requests in the Review stage
-
The Review stage on every project page has been empty since #5 because orbit had no concept of pull requests. This PR adds the full tracking stack: a new
pull_requeststable mirroring the issue table's shape, webhook updates on every PR lifecycle event (opened, edited, closed, reopened), periodic sync as a safety net for dropped webhook deliveries, and handler logic that populates the Review stage with open non-draft PRs sorted by activity. Draft PRs are tracked but hidden — they become visible the moment the draft flag flips.PR activity also factors into the home page's project-row dot state: a project with a recent PR but no other movement now glows sodium instead of sitting quiet.
Claude removal
The
claudepackage is gone entirely. Its only job was summarizing PR diffs into history entry text on merge, but the summaries were verbose and often read worse than the PR title they replaced. The merge handler now storespr.Titleverbatim as the history entry's summary. The trade is automation for discipline: write good PR titles and descriptions, and the History stage reads well for free.Full audit:
claude/package deleted, import chain cleaned throughmain.go,api/api.go,api/webhooks.go, andconfig.go,ORBIT_CLAUDE_API_KEYenv variable retired,forgejo.GetPullRequestDiff(orphaned after the removal) also deleted. ThehistorySummaryhelper in the web layer used to prependPR #N:to entries with a PR number attached; that prefix is gone too, matching the new "lean on the PR title" posture.Scope expansion: sync tests
sync.gowas untested before this PR. Adding the new PR sync was an opportunity to lock in the existing issue sync's behavior with tests, sosync_test.gocovers both paths — 4 issue sync cases and 4 PR sync cases. Any future regression in the shared sync infrastructure will now be caught by the suite.Follow-ups
- Expand-on-click UI for PR bodies — the body field is stored, the UI is a separate spec.
- Deleted-on-Forgejo PR reconciliation — rare edge case, manual cleanup for now.
- Review metadata (approved / changes-requested badges) if they prove useful.
- Stacked PR / branch graph visualization using
base_refandhead_reffields.
Closes arne/orbit#7.
- 14:01 Auto-archive design specs when PRs finalize them
-
Finished design specs used to linger in the Design stage forever — the spec would stay visible even after its implementation had shipped. This PR makes them archive automatically: add a
Finalizes: 2026-04-05-foo.mdline to a PR's body, and when you create the PR, orbit's webhook handler deletes the spec file from the PR branch via the Forgejo contents API. The deletion becomes part of the feature PR's diff, so the spec disappears atomically with the work that finalized it.Deletion, not a move to docs/archive
Git is already the archive — every byte is retrievable via
git log --followor Forgejo's commit browser forever. A second on-disk location would be redundant maintenance.Orbit-side, not AI-side
The design-folder guardrail (#2) blocks Claude from editing
design/outside a design session, but the guardrail only applies to Claude Code tool calls. Orbit-the-server running in systemd calls the Forgejo API directly and sidesteps the hook entirely. The guardrail stays unmodified.Convention
A line in the PR body starting with
Finalizes:(case-insensitive), then one or more comma-separated spec references. Accepts bare filenames (foo.md),specs/foo.md, ordesign/specs/foo.md— all normalize todesign/specs/<basename>. Basenames are validated against^[A-Za-z0-9._-]+\.md$to reject path traversal attempts and unexpected extensions.Also
Filters
README.mdout of the Design stage at thedesigns.Sourceboundary — the scaffold's placeholder was cluttering the list.Known cuts
- No
edited/synchronizeaction handling; onlyopened. To add aFinalizes:line after the PR exists, close and reopen. - No git blob fallback for deleted specs in the
design-previewroute — once gone from HEAD, the preview 404s on that path. Forgejo's commit browser remains the retrieval path. - Label-based triggers are deliberately out of scope. PR body only.
- No
- 13:34 Render home and project pages with the design system
-
PR #4 committed to orbit's visual direction in
design/but left the live web layer on its MVP-era generic styles. This PR ports the styleguide into production:web/static/orbit.cssis now the design system,web/templates/*render with the new components, and both pages reflect the new flow-based structure.Home page
A single column of
.project-rowentries ordered by last-activity descending. Each row shows the project slug and name, a one-line summary of the most recent history entry, and a relative time (3h,2d,1w). The leading dot glows sodium if the project moved within the last 7 days, muted grey otherwise.Project detail page
Five flow sections — Ideas, Design, Build, Review, History — with the mapping specified in #4's spec. Empty stages collapse. History is grouped by calendar day with
.day-dividerheaders, and entries beyond the first 12 collapse into a<details class="entry-expand">withshow N earlier entriesas the summary.Activity helpers
New
web/activity.gomodule exposesActiveWindow,isActive(t time.Time), andprojectLastActivity(project, ideas, issues, history). The latter aggregates timestamps across all child entities so a project's dot state reflects its most recent movement across any flow domain.Font loading
Iosevka
.woff2files vendored underweb/static/fonts/and loaded via<link rel="preload">inweb/templates/layout.html, matching the design spec's FOUT-elimination approach.Also: UTC timestamp bugfix
While evaluating this PR live against the production orbit instance we hit a latent sync bug:
sync.goandapi/webhooks.gowere callingtime.Parse(time.RFC3339, ...)which returns atime.Timewith a fixed-offset zone. Themodernc.org/sqlitedriver serializes those viaTime.String()(producing"2026-04-05 13:11:46 +0200 +0200") and then cannot scan them back intotime.Time. Store tests passed because they used.UTC(), but production data was unreadable once the new home and project handlers started readingIssue.ForgejoUpdatedAtfor the activity rule.Fixed at the store boundary in
UpsertIssue: everyforgejoUpdatedAtis normalized with.UTC()before hitting the driver. The fix is bundled into this PR because it blocked live evaluation.Known cuts
Per-issue triage between Ideas/Design/Build stages (no schema yet), open-PR tracking for the Review stage (addressed in #13), and design-spec last-activity from file mtime (
designs.Filedoesn't expose one) — all deferred to follow-up work. - 09:55 Design a quiet UI for orbit — monochrome, dots, and Iosevka
-
Orbit's MVP rendered with browser-default
system-uiand a handful of ad-hoc styles. That was fine for getting the data flowing but gave orbit no aesthetic identity — no sense of what a glance at the project page should feel like. This PR commits to a coherent visual direction that scales from atoms to pages without rework.The guiding principle: orbit should take little space and let the content it holds — projects, ideas, issues, specs — do the showing off. Every choice falls out of that.
Palette
Warm near-black background with warm cream text, a single sodium-orange accent for interaction and active state. Everything else is warm greys. Dark by default, light theme via
prefers-color-schemeonly — no manual toggle to clutter the chrome.Typography
Iosevka carries everything — headings, body, labels, code. One instrument, one font. Its narrow proportions are the most literal answer to "takes little space". Self-hosted from
design/fonts/(woff2, weights 400 and 700, SIL OFL). Font loading uses<link rel="preload">plusfont-display: blockto eliminate the flash-of-unstyled-text.Brand
A single sodium-orange dot. No wordmark — the word "Orbit" already appears in the text whenever orbit introduces itself, so the brand mark is pure graphic.
Dot vocabulary
Three tones carry a universal status language. Active (sodium) for flow items that moved within the last 7 days. Stale (warm grey) for flow items that haven't. Done (muted sage) for every entry in the History archive. Flow items decay with time; history is terminal and uniform. The rule is implemented server-side via a single
isActive(t)helper plus anActiveWindow = 7 * 24 * time.Hourconstant.Component inventory
.site-header,.section-heading,.project-row,.entry-row,.entry-list,.entry-expand,.meta-strip,.day-divider, plus the existing.brand,.dot,.dot-active,.dot-done,.btnatoms. CSS subgrid keeps entry-row columns aligned across variable label widths.Project detail page
Flow-based stage model: Ideas → Design → Build → Review → History. Every item lives in one stage at a time. Empty stages collapse. Raw ideas and untriaged issues sit in Ideas; developing ideas and in-progress specs sit in Design; ready-to-ship work sits in Build; open PRs sit in Review; merged PRs and completed events sit in History.
The spec at
design/specs/2026-04-05-orbit-design-system.mdis the canonical reference.Known cuts
This PR ships the styleguide under
design/and page previews only. Porting toweb/static/orbit.cssandweb/templates/*.htmlis a deliberate follow-up so the system can be lived-with-in-isolation first. - 07:11 Ship a structural init_design scaffold separate from styling choices
-
The original
init_designMCP tool produced a three-file scaffold (one HTML, one CSS, one markdown spec) that conflated file structure with visual design choices. Every project that called the tool inherited a specific palette, typography, and layout system, whether that fit the project or not. The tool needed to teach conventions — folder layout, file naming, the separation between styleguide and page previews — without committing to an aesthetic.This PR rewrites the scaffold to be structural. The output is a folder tree under
design/with named sub-folders forspecs/,preview/,components/, andfonts/, a singlestyle.cssorganized into banner- commented bands (tokens / reset / elements / forms / components / prototype chrome), atheme.jsplaceholder, and README files explaining each location. No opinionated colors, typography, or component shapes — just the structure.Prototype chrome namespace
Styleguide-only UI (breadcrumbs, section wrappers, nav chrome for page previews) lives under a
.prototype-*class namespace so downstream porting to a real web layer knows what to keep and what to strip. When a real design system later builds on top of this scaffold, the prototype chrome can stay indesign/and get omitted from the ported CSS.Page previews as navigation
The scaffold includes
design/preview/index.htmlas the canonical entry point for interactive previews, with a.prototype-breadcrumbfor navigating between preview pages. Real dev chrome, not part of any page being previewed.Tests
mcp/scaffold_test.goasserts the exact folder layout and file list so the scaffold contract is pinned against regressions. - 05:40 Lock the design/ folder outside a design session
-
As orbit grew and
design/became the canonical styleguide the web layer would be ported from, accidental AI edits to those files during unrelated feature work became a real risk. One thoughtless rewrite ofdesign/style.cssduring a Go refactor would silently desync the styleguide from the web layer.This PR installs a
PreToolUsehook (.claude/hooks/design-session-guard.sh) that enforces a two-mode rule: design work and feature work are strictly separated. In normal mode,Edit,Write,MultiEdit, andBashoperations underdesign/are blocked. To work on the styleguide, the user creates a session marker file (.claude/design-session-active) from a separate terminal outside Claude Code. While the marker is present,design/is unlocked — and symmetrically, everything else is locked so feature work can't leak in during a design session.The hook is deliberately AI-unreachable in every direction: Claude cannot create, delete, read, or otherwise manipulate the marker file via any tool, because the whole point of a mode flip is that the human makes it.
Detection
For
Edit/Write/MultiEdit, the hook compares the resolved absolute path against the repo root and classifies writes as in-design or out-of-design. ForBash, it uses a path-token regex —(^|[^[:alnum:]_])design/— that matchesdesign/,./design/,/design/and quoted variants, but deliberately does not matchdesigns/(the Go package) orredesign/.Reads are always allowed
The hook never blocks reading from
design/. Claude can reference the styleguide for context at any time, from any mode.
- 21:21 Read design files from the local checkout with a Forgejo fallback
-
Previewing design files through orbit's web dashboard required committing and pushing to Forgejo for every change, then waiting for the API cache to catch up before the preview reflected the update. That's a slow loop when you're iterating on a stylesheet or a markdown spec.
This PR points orbit at the local working-tree checkout for design reads, with the Forgejo API as a fallback when the checkout is unavailable. Set
ORBIT_SOURCE_ROOTto the directory containing your repo checkouts (e.g.,~/source), and orbit resolves{sourceRoot}/{owner}/{slug}/design/for each registered project. Unset it to fall back to Forgejo-only reads.The
designspackageNew
designs.Sourceinterface with three implementations.LocalSourcereads from the working tree and has traversal and symlink-escape guards — it resolves symlinks and re-validates containment before reading to close the TOCTOU window.ForgejoSourcewraps the existingforgejo.Client.ChainSourcecomposes them, trying each in order and falling through onErrNotFound.Web and API handlers now depend on the
Sourceinterface rather than callingforgejo.Clientdirectly for design reads. Production wiring inmain.gocomposes aChainSourceofLocalSource+ForgejoSourceso local wins when present.Security
The design-preview route enforces an extension allowlist (HTML, CSS, JS, images, fonts, markdown, text — 15 types total). Unsupported extensions return 415. Every design response carries
Cache-Control: no-storeto prevent any layer from caching stale content while iterating.Also
Adds
CLAUDE.mdwith the repo conventions that will grow alongside the codebase: squash-merge policy, deployment notes, source layout.