Skip to content
Blink

Docs

Reference

Every command, exit code, entity type, field and configuration key, with what each one does and what it does not do. This is the half you look things up in. The getting started page is the one you read start to finish.

npm install -g @weloin/blink

Installing and upgrading

The install command is the strip at the top of this page, above the section list. It is one line, npm install -g @weloin/blink, and it is the same line whether you are installing for the first time or moving to a new machine.

Blink ships as a native binary, one per platform, so nothing needs a Node runtime to run it. npm is still the delivery channel: the package you install is a small wrapper whose post-install step puts the right binary for your machine where the blink command points. A machine with no npm therefore has an install problem rather than a runtime one, and the way round it is to fetch the platform package directly rather than to install Node.

Upgrading

Upgrading is three steps: install the package, run blink init in the project, then run /blink:setup in your agent. Only the first one is about the tool. The other two are what bring the project itself up to the version you just installed, and skipping them is why an upgrade can look like it did nothing.

npm install -g @weloin/blink@latest

Step one. Nothing has to be stopped or restarted first.

Same channel as the first install, with the tag spelled out. Without @latest npm is entitled to leave the version you already have in place.

blink --version

Prints the binary's version, then what the project you are standing in has installed.

The second half of that output is the install report, and it is the shortest way to see whether the next two steps have anything to do. It walks up from the current directory looking for a blink.json, and when it finds one it lists every file Blink scaffolded whose stamp is behind the binary you just installed, then names the version it wants to bring them up to. Outside a project it prints the version line and nothing else.

Step two: re-run the scaffold

The workflow contract lives in your repository rather than inside the install, which is what lets an agent on a machine that never installed Blink still write the format correctly. See how agents are discovered for why it is built that way. The cost of that design is the two steps that follow: a new binary does not touch the instructions sitting next to your code, so a project carries whatever version of the contract it was last scaffolded with until you ask for a newer one.

blink init

Step two. Takes a path and defaults to the current directory. Safe to run on a project that already has everything.

Every file Blink scaffolds carries a version stamp. On this second run init compares each stamp against the binary and reports what it did, file by file. Running it twice on the same version writes nothing at all. The report to read closely is kept, which is what step three is for.

What blink init reports against each file
ReportWhat happened
createdThe file was not there. Written.
appendedA CLAUDE.md or AGENTS.md was there without a Blink block. The block was added to the end and the rest of the file was left byte for byte.
updatedThe stamp was behind and nothing had touched the file since Blink wrote it. Replaced, with the two versions named.
keptThe stamp was behind and the file had been touched. Left exactly as it is, and handed to step three.
skippedNothing to do, with the reason given: current when the stamp already matches, already exists for a file that is yours rather than Blink's, and a directory name when the examples were not written because that directory already holds markdown.

Note

Whether a file has been touched is judged from git, and only a file the repository shows nobody has edited since Blink wrote it gets replaced. Outside a git repository there is no such evidence, so every stale file comes back kept and the only thing that moves is the marked block in CLAUDE.md and AGENTS.md. A project whose scaffolded files have been committed more than once reads the same way. Neither is unusual, which is why the third step is a step and not a footnote.

Three files are yours rather than Blink's and are never upgraded: blink.json, the project entity, and the example entities. A later run reports them as already there and leaves them.

Step three: reconcile what init kept

kept is the report of restraint rather than a warning that something failed. Nothing in an upgrade can take back a skill you customised. /blink:setup is the other half of that promise: it shows you what the newer version would have written for each of those files, and overwrites only what you agree to.

/blink:setup

Step three. Typed in your agent, standing in the project.

It reads the version stamp out of every file Blink scaffolded and compares each one against the binary you installed in step one. What is still stale comes back in a single table, alongside anything else the project has drifted from: an instruction block that is missing or old, a skill that was never installed, an agent roster you do not have. Then it asks, once, and writes nothing until you say yes. Run blink --version again afterwards and the install report should say every file is current.

Everything /blink:setup checks

What init will not do

  • It never creates the directory you point it at. A mistyped path is refused with the mkdir you would have wanted, rather than scaffolded into by accident.
  • It never overwrites a blink.json it cannot read. A malformed config or a schema version from the future stops the run, because scaffolding blind would mean guessing at a content directory the config may already have moved.
  • It writes only inside the directory you point at, and it is the only command in the product that writes to your repository at all.

It ends by running the validator over the result and exits with the validator's code, so a scaffold that landed on top of a project with a broken record says so immediately.

What init and validate exit with

The CLI, command by command

Every command is blink <command>, and the ones that act on a project take its root as a trailing path. That path defaults to the current directory, with two exceptions worth knowing before you hit them: add and rm take it as a required argument, and the run family takes it as --project.

Nothing here writes to your repository except init, and nothing starts a server except open, start, restart and serve. Everything else reads.

Finding your feet

Typing blink on its own prints the command list, then a report of what the project you are standing in has installed. It starts nothing and opens nothing. That is a report rather than a failed launch, and it is the fastest way to see that a project's scaffolded skills are behind the binary.

blink

The commands, plus this project's install. No server, no browser.

blink --version

The binary's version, then the same install report. Always succeeds.

Both of those find the project by walking up from the current directory until they meet a blink.json, so they answer from a subdirectory. Outside a project they print the general half and stop.

blink start --help

Every command answers --help with its own arguments and flags.

Note

blink <path> is a deprecated alias for blink add <path>. It registers the project and prints a notice on stderr. It does not open a dashboard, which is what people typing it usually expect, and blink open is where that went.

Scaffolding and registering

Scaffolding a project and registering it are two commands, because they answer two questions. init puts the format and the agent instructions in your repository. add tells the dashboard on this machine that the project exists.

blink init

Scaffold blink.json, the content directory and the agent skills. Defaults to the current directory.

What init writes, and what it does on a second run

blink add .

Register this project. The dot is required: add takes the path to register.

That dot is the thing everybody gets wrong exactly once. init and validate default to the current directory, so a bare command works; add does not, because registering the directory you happen to be standing in is not a safe default for a machine-wide list. Registration writes to Blink's own state and never to your repository, and it starts nothing.

The id that blink add records is fixed, and editing blink.json afterwards does not move it

blink ls

Every registered project, id and path. A project whose blink.json has vanished is marked (missing).

Missing is shown rather than cleaned up. A path that has moved or a checkout that is not mounted right now is information, so the row stays and says so instead of disappearing.

blink rm ~/code/old-project

Unregister. Takes a path or the registry id from blink ls. Deletes nothing on disk.

Checking the record

blink validate is the linter for the format: filenames, frontmatter, statuses, references between entities. It is the command to put in CI and the command an agent runs after it writes.

blink validate

Lint this project. Defaults to the current directory; takes a path.

It prints the problems it found, then a count of entities by type, then a line of error and warning totals. Warnings are allowed and do not fail it, which is the whole reason it works as a gate: a rule that fails a build over a filename that disagrees with its frontmatter is a rule somebody switches off.

It reads this checkout only. Other worktrees of the same repository are deliberately not folded in, so a lane's half-written task can never fail the main branch's build. The dashboard folds them; the gate does not.

blink drift is the other read, and it compares the tracker against git history rather than against the schema. It reports and never writes.

blink drift --stale-days 30

Report where the tracker and the repository disagree. Never writes.

blink drift flags
FlagOmittedWhat it does
--jsonHuman-readable reportEmits the whole report as JSON, which is what /blink:sync reasons over
--stale-days <n>14Days of silence before an in_progress task is questioned. Whole numbers, 1 or more

Its findings come in two strengths and only one of them is a failure. A contradiction is established when the repository proves the tracker wrong, such as a task still open across a commit that says it shipped. A question is a suspicion, such as a task that has been in_progress longer than the stale window. Questions alone are reported and still succeed, for the same reason warnings do not fail validate.

Repairing what it finds is a separate job, because deciding whether work really finished needs a human. drift reports; /blink:sync turns a report into repairs you confirm one at a time. Conflating the two is the usual mistake here.

Driving a run

Three commands exist for the executor rather than for you: gates, plan and the run family. They are documented because reading what your agent is about to do is the point of the product, and because two of the three are useful on their own. What waves, tiers and lanes mean is in running work; this is the command surface.

blink gates

Print the commands that constitute "green" here, in run order. Detects, never runs.

Detection is a fixed walk over files in the project root, in four categories: typecheck, lint, test, build. Each category takes the first hit from a package.json script, then a Makefile target, then, for test only, an ecosystem marker such as a Cargo.toml. The package runner comes from whichever lockfile is present. A blink validate gate is added when the content directory exists.

The count it prints always says how many gates run the code, not just how many gates there are. A project whose only gate validates frontmatter has checked its tracker and not one line of its change, and the command says so in as many words rather than reporting a tidy list. It succeeds only when at least one gate exercises the code.

It is a proposal rather than authority. Nothing is executed, nothing is cached, and the list is meant to be edited in pre-flight when it guesses wrong. --json emits the same report for a caller to reason over.

blink plan m-04

Resolve an invocation into a work set, classify its tier, print the pre-flight plan. Writes nothing.

The invocation is variadic and takes task ids, a milestone id, the word next, or free text. Passing nothing means next. The plan it prints is the whole pre-flight: what resolved, the waves it falls into, the tier, and the gates that would run.

blink plan flags
FlagOmittedWhat it does
--jsonHuman-readable planEmits the same plan as JSON
--tier <mode>Classified from the work setForces the tier: inline, subagents or orchestrators. The fourth value ask is refused here, because forcing it would name no tier at all
--project <path>.Project root, since the positional argument is the invocation

It answers one of three ways, and the answer is in the exit code as well as in the output: dispatch this, hand off to planning because nothing resolved, or resume the run that is already open. See exit codes.

blink run is the write surface for a run record, and it is five subcommands rather than an editor because an orchestrator updates run state constantly. Each one prints a single line instead of the file, so keeping the ledger never pulls the record into the caller's context.

blink run new --tasks t-0042,t-0043 --tier subagents --lanes t-0042:1,t-0043:2

Creates the record and prints the new id alone, so a caller can capture it.

blink run new flags
FlagOmittedWhat it does
--tasks <ids>RefusedComma-separated task ids this run covers, in dispatch order. Required: a run with no tasks executes nothing
--tier <mode>askinline, subagents, orchestrators or ask. The default is honest rather than a guess: the record is written before the tier is chosen
--gates <cmds>No gates recordedComma-separated gate commands, in run order. Commands contain spaces and never commas, which is why a comma is the separator
--title <text>Run <id>Run title. A default title carries no information, so it is left out of the filename too
--lanes <pairs>No lanesComma-separated task:wave pairs seeding the confirmed layout, every lane pending. Folded in before the file is written, so the layout lands whole or not at all
--project <path>.Project root
blink run lane x-0bl3ld t-0042 --state merged --branch lane/t-0042

Add or update one lane. An update keeps every field it does not name.

blink run lane flags
FlagOmittedWhat it does
--state <s>Unchanged, or pending on a new lanepending, dispatched, ready, merged, failed or skipped
--wave <n>UnchangedDispatch wave. Required when the lane is new
--path <wt>UnchangedWorktree root for the lane
--branch <b>UnchangedBranch the lane works on
--attempts <n>UnchangedFailed dispatches so far
--project <path>.Project root

A lane may only name a task the run already covers. The first lane that moves promotes the run from planned to running, and the command says so on the line it prints.

blink run event x-0bl3ld "wave 1 dispatched, 2 lanes"

Appends one line to the run's log. Blank text is refused.

blink run finish x-0bl3ld --status blocked --reason "gate 3 fails on main"

Ends the run. done, abandoned or blocked, defaulting to done.

--status blocked requires --reason, because a blocked run that does not say why cannot be picked up. planned and running are not endings and are refused here: a run reaches running by dispatching a lane, and nothing walks it back.

blink run show x-0bl3ld

A derived summary: status, tier, tasks, gates, lanes by state. Never the file, never the body.

show is the only one of the five that scans the project, and it keeps only the problems belonging to this run's own file. That is deliberate: it is the read an orchestrator makes before it acts, so a run naming a task that no longer exists has to surface here rather than read as a clean table. --json gives the same summary to a machine.

The server

One server serves every registered project, so these commands are about the server rather than about a project. None of them takes a path.

blink open

Open the dashboard, starting the server first if none is running.

Whether that command returns depends on what it found, and the line it prints tells you which happened. If a server was already up it opens the browser at that server and exits. If there was none it becomes the server and stays in the foreground until Ctrl+C, which also releases the lockfile. It lands on the fleet view, every registered project at once, rather than on one project.

blink start --port 7800 --open

Start the server in the background and show it. Reports the running one if there already is one.

blink status

Pid, port, url and uptime, plus this project's install. Succeeds only while a server is running.

status answers one question, and the exit code answers only that one. It also prints the install report of the project you are standing in, in both states, because a stale skill file is worth knowing about either way. That report never moves the exit code: giving one number two jobs would break every script that reads it. --json emits the server and the install report as one document, with every field present in both states and null where it has no value.

blink stop

Stop the running server. Nothing running is a success, not an error.

blink restart

Stop if running, then start. Works from either state, and says which one it found.

blink serve --port 7800

The server in the foreground, no browser. This is exactly what start launches.

serve is a documented command rather than a private argument shape, so a ps line is explicable and so the server can be run under systemd or launchd directly.

Server flags
FlagOmittedWhat it does
--port <n>7777Port to bind, on start, restart and serve. Rejected before anything binds if it is not 0 to 65535
--openNo browserOn start only. Opens the dashboard once the server answers, and never on a start that failed
--jsonHuman-readable reportOn status only

The default port walks. If 7777 is taken the server tries 7778, and so on for twenty ports, so the url you memorised may not be the one serving today. blink status is the answer to which one it is. A port you pin with --port walks the same way; only --port 0, which means any free port, does not.

Setting BLINK_NO_OPEN to any non-empty value stops every command from opening a browser, blink open and blink start --open included. It is a presence check rather than a value check, so BLINK_NO_OPEN=0 suppresses the browser just as BLINK_NO_OPEN=1 does. Neither stops the server.

Requiring a password

Two commands, both of which write one file in Blink's own state and neither of which takes effect until the server restarts.

blink set-password --user alex

Prompts twice and never takes the password as an argument. --user defaults to blink.

blink clear-password

Removes it again, confirming first. --yes skips the confirmation.

What the password does, and what it does not do

One server, and every command idempotent

Exactly one server runs at a time, enforced by a lockfile in ~/.blink/ holding the pid, the port and the start time. The lock is trusted only while its pid is alive and its port answers, so a stale lock left by a crash is recovered automatically and the recovery is reported rather than silent.

Every command is idempotent and every no-op succeeds. A second blink start reports the server already running. blink stop with nothing running says so and exits 0. blink clear-password with no password set says so and exits 0. Two blink open commands racing for the same lock end with one server and two browsers pointed at it.

The registry of projects lives beside the lockfile, and so does the server's log, which is where a background start that failed explains itself. The whole tree is mapped in where Blink keeps its own state.

What has no command, on purpose

Notifications have no command at all. They are configured by file rather than by flag, in the notify block of blink.json and in your own config under ~/.blink/notify/, layered in that order. That is a design rather than a gap, and the rules are in notifications.

There is also no command that creates a task, moves one, or writes a decision. Entities are markdown files, and writing them is what your agent does through the skills. The one exception is the run family above, which exists because a run record is written by a machine at machine speed.

Exit codes

Every command sets an exit code, and the codes mean the same thing across the whole CLI. 0 is success. 1 is a real answer that happens to be negative. 2 means the question could not be asked: the project could not be read, or an argument was refused before anything was touched.

2 is not a worse 1. A 1 from blink validate says your content is wrong; a 2 says nothing was read, so the content has not been judged at all. Treating the two the same is how a broken config gets reported to a team as a validation failure.

blink validate, and blink init

Exit codes for blink validate and blink init
CodeMeaning
0Clean. Warnings are allowed and still exit 0
1Validation errors found
2The project could not be read at all: no blink.json, an unsupported schemaVersion, or malformed config. For init it also covers a path that does not exist, in which case nothing was written

blink init ends by running the validator over what it wrote and exits with its code, so the two share a table rather than having two that must be kept in step.

Note

Warnings exit 0 on purpose. That split is the whole reason blink validate works as a CI gate: real errors always fail the build and advisory problems never do. A pipeline wired to fail on any output at all gets switched off the first week.

blink plan

blink plan writes nothing, so its codes report what should happen next rather than whether anything went wrong.

Exit codes for blink plan
CodeMeaning
0A plan with tasks in it, ready to confirm and dispatch
1Nothing resolved. Hand off to planning rather than invent work
2The project root could not be read at all
3A run is already running. Resume it rather than starting a second one

3 beats 1 even when nothing resolved, because reconciling the live run comes before deciding there is no work. Invoking the executor again while a run is open is how somebody ends up with two runs, and this code is what stops it.

blink gates and blink drift

Exit codes for blink gates
CodeMeaning
0At least one detected gate exercises the code
1Nothing but blink validate was found, or no gates at all. A caller that shells out must not read that as green
2The project root could not be read at all
Exit codes for blink drift
CodeMeaning
0No established drift. Open questions on their own still exit 0, because a question is not a failure
1At least one established contradiction, or a validation error
2The project could not be read at all

Every other command

Exit codes for the remaining commands
CommandCodes
blink, blink --version, blink lsAlways 0. A report cannot fail, and a stale skill file is not a reason to tell a script the binary is broken
blink id <type>0, or 2 for a type that has no generated id
blink add <path>0, or 2 when there is no readable blink.json at that path
blink rm <path>0, or 1 when nothing in the registry matched
blink open0. Interrupting the foreground server it started exits 130, the shell convention for SIGINT
blink start, blink restart0 whether it started one or found one already running. 1 when the server did not come up, or, for restart, when the running one refused to stop. The server log says why
blink stop0, including when there was nothing to stop. 1 only when a running server refused to die
blink status0 when a server is running, 1 when none is. It reads as a shell condition, and the install report never moves it
blink serve0. Losing the startup race to another server is a success: the single instance you asked for exists
blink run <sub>0, 1 when a write was refused because it would break the file, 2 when the run could not be read or an argument was rejected before anything was touched
blink set-password0, 1 when the prompt was cancelled, 2 for an empty password or two that did not match. Nothing is written in either failure
blink clear-password0, including when no password was set. 1 when the confirmation was declined

An unexpected error anywhere exits 2 with its message on stderr, which keeps the rule above true even for the cases nobody enumerated.

The format

One markdown file per entity, in your repository, in git. The YAML frontmatter is state and the markdown body is the thinking. Nothing about the format is Blink-specific once it is written, so the plan is reviewable in a pull request, survives a change of tooling, and needs nothing installed to read. cat works, a diff works, and a teammate who has never heard of Blink can read the whole thing.

This section is the file on disk. The seven types and their status enums are in entity types and statuses, and the field table for each type is in every field, type by type.

The entrypoint, blink.json

A blink.json at a directory's root is what makes that directory a Blink project. There is no registration step that changes this and no state elsewhere that can contradict it: no file, no project.

blink.json
{
  "schemaVersion": 1,
  "name": "My Project",
  "id": "my-project",
  "contentDir": ".blink"
}

What blink init writes. Four lines, and a project with only these behaves the way every default describes.

Everything else is something you add, and there is not much of it. This is the whole file with every key Blink reads present at once, which is not a file you would write: past the first two lines, a key is worth putting in only when you want something other than the default.

blink.json
{
  "schemaVersion": 1,
  "name": "My Project",
  "id": "my-project",
  "contentDir": ".blink",
  "color": "#0a84ff",
  "iconPath": "docs/logo.svg",
  "iconText": "MP",
  "git": { "activity": true },
  "agents": { "scan": [".claude/agents", ".claude/skills", "AGENTS.md"] },
  "worktrees": { "track": true, "ignore": [], "max": 12 },
  "notify": { "settleMs": 1000, "burstCap": 25, "muted": false }
}

Only schemaVersion and name are required. The blocks are shown at their defaults; the three above them take a value of yours, and the table says what happens when you leave each one out.

There is no settings screen, and that is the point of the file. A project's setup travels with its repository, so a clone on another machine behaves the way the project asked for rather than the way that machine was once configured.

Every key blink.json accepts, with its default
KeyRequiredDefaultDoes
schemaVersionyesnoneThe format version this project is written against. A project declaring a version higher than your Blink understands refuses to load, and the message tells you to upgrade
nameyesnoneThe display name, everywhere the project is named
idnoname slugifiedThe key the project is registered and addressed under. Lowercased, every run of non-alphanumerics collapsed to one dash
contentDirno.blinkWhere the entity files live, as a path relative to the project root. Must stay inside the root
colornoderived from the idThe project's tile colour in the rail and on the dashboard. Six-digit hex only, such as #0a84ff
iconPathnononeA project-relative path to an image the rail draws instead of the initials
iconTextnoinitials from the nameOne or two letters or digits to draw instead. iconPath wins over it
git.activitynotrueWhether the Activity view reads the git log. False turns that feed off and leaves everything else working
agents.scanno.claude/agents, .claude/skills, AGENTS.mdThe paths scanned for agent definitions
worktrees.tracknotrueWhether Blink reads this project's other worktrees and folds their trackers into one board
worktrees.ignoreno[]Globs matched against a lane's directory name and its path from the repo root
worktrees.maxno12How many lanes get scanned. Beyond it the most recently written win, and the view says how many were left out
notify.settleMsno1000How long events are held before they are collapsed and emitted. A whole number of milliseconds, 0 to 60000
notify.burstCapno25How many events one batch may itemise before progress events roll up into a single line
notify.mutednofalseSilences this project's notifications

Every absent key above is resolved to its default while the file loads, so nothing downstream ever asks whether a block was written. The notify block is the single exception: its absent fields are left absent on purpose, because absence is data there. A field nobody wrote falls through to your own machine-local preferences, which outrank this file. Reasoning about notify from the way worktrees behaves gives the wrong answer.

The precedence rules for notify

Note

Values are checked, key names are not. An unrecognised key is dropped while the file loads, without a word, so a misspelt settleMS or ignores reads as the default and looks like a setting that does nothing. That stripping is deliberate: it is what lets an older Blink keep serving a project that uses a newer key, without a format version bump locking it out.

A value that fails its check is treated the opposite way. The project does not load at all, and every command run against it says which key is wrong and why, so a hex colour one digit short takes the whole project off the dashboard rather than falling back to a default. Put the value right and it returns on its own.

What a project that will not load exits with

The task-shaped view of these keys, one job at a time, is in configuring a project.

The directory layout

One file per entity, named <id>-<slug>.md, inside a directory that names its type. blink init writes this tree, and after that it is yours to write into by hand or through an agent.

the content directory
.blink/
  project.md                          the project overview and its goals
  SCHEMA.md                           the field reference, written by blink init
  milestones/m-01-foundations.md
  tasks/t-0bj4qz-jwt-refresh.md
  decisions/d-003-parse-on-read.md
  risks/r-002-flaky-ci.md
  docs/brainstorm-auth-models.md
  runs/x-0bl3lk-milestone-3.md

The directory determines the type. Nothing in the frontmatter says what an entity is, so a task file moved into docs/ is not a misfiled task. It is a doc, it is read as one, and blink validate reports it as a doc with a status outside the doc enum. The fix is to move the file back, never to add a field.

Each entity directory is read one level deep. A subdirectory inside tasks/ is not scanned and not reported, so an archive/ folder made to tidy up a long backlog takes every task in it out of the project silently. Use archived: true on a finished task instead, which hides the card and keeps the file counted.

At the top of the content directory, project.md, SCHEMA.md and README.md are expected. Any other loose markdown there is a warning saying it was ignored, because a file at that level belongs to no type.

Note

SCHEMA.md is documentation only. Nothing reads it at runtime, so editing it changes nothing about what is validated: the authority is blink validate. Editing it has one real effect, and it is the opposite of the one people expect. blink init upgrades the files it wrote and you have not touched, so an edited SCHEMA.md is reported as kept and stops receiving updates.

What blink init upgrades, and what it keeps

A worked entity

Frontmatter between the fences, prose below them. Here is a decision in full, because a decision is the entity whose body carries the most weight: its frontmatter is bookkeeping and everything of value is underneath.

decisions/d-003-parse-on-read.md
---
id: d-003
title: Parse entity files on read, not on a watch event
status: accepted
date: 2026-08-14
created: 2026-08-14T09:10:00Z
updated: 2026-08-14T09:10:00Z
tags: [architecture, performance]
---

## Context

The dashboard rebuilds a project's snapshot whenever a file under the content
directory changes. An agent writing a wave of tasks touches twenty files in a
second, and each write arrives as its own event.

## Options

**Parse on the watch event.** Keep a cache keyed by path and update the one
entry that changed. Fastest per event, and the cache is a second copy of the
truth that can disagree with the disk.

**Parse the whole project on read.** Throw the parse away every time. More work
per rebuild, and there is nothing to invalidate.

## Decision

Parse on read. A project of a few hundred files parses in single-digit
milliseconds, which is well inside the debounce the watcher already applies, so
the cache would buy time nobody can perceive and cost a class of bug that is
very hard to see: a stale card that is correct again after a restart.

## Consequences

- Snapshot cost grows with the project rather than with the edit. If a project
  ever gets large enough for that to show, the fix is to batch the rebuild, not
  to reintroduce the cache.
- Every reader gets the same answer, because there is only one answer.

The frontmatter is the half that gets the attention and the body is the half that is worth anything six weeks later. A task with an empty body is a title: it says what somebody meant to do and nothing about what they knew when they decided to do it. Write the second half. It is the one people skip and then regret.

The body is free-form markdown with one convention worth knowing. A reference written as [[t-0042]] renders in the dashboard as a chip that opens that entity, and an id nothing in the project claims renders muted and inert rather than vanishing, so a typo is visible. The same id inside backticks stays plain text, which is how you name an id you do not want to be a link.

Filename rules

What each type's file is called
TypeFilenameNotes
Milestonemilestones/<id>-<slug>.md
Tasktasks/<id>-<slug>.md
Decisiondecisions/<id>-<slug>.md
Riskrisks/<id>-<slug>.md
Runruns/<id>-<slug>.mdWritten by the executor, not by hand
Docdocs/<id>.mdExactly the id. No numeric prefix and no extra slug
Projectproject.mdLeave id out of the frontmatter. It defaults to project

For the first five types the -<slug> is optional and the id never is, so tasks/t-0bj4qz.md is accepted. The doc row is the one people get wrong. A doc's id is already a slug, and its filename is exactly that id with .md on the end, so docs/auth-models-notes.md for a doc with id auth-models is a filename warning rather than a tidier name.

A mismatch is a warning and not an error. The file still loads, keyed by its frontmatter id, because the frontmatter is authoritative and a rename should never make an entity disappear.

Ids

New ids are timestamps rather than counters: seconds since 2026-01-01 UTC, encoded in lowercase base36 and left-padded to exactly six characters behind the type letter, as in t-0bj4qz. The padding is what keeps sorting by id the same as sorting by creation time.

blink id t

Prints one id and nothing else. Takes t, m, d, r or x, or the words task, milestone, decision, risk and run.

Never allocate an id by counting up from the highest one already in the tracker. That is the whole reason for the scheme: two writers on two clones both find the same next number, both take it, and the collision only surfaces at the merge, after both files are written. Timestamp ids collide only when two writers create the same type of entity in the same literal second, and blink validate catches that residual case as a duplicate id error.

A run's letter is x, because r is already the risk. Doc ids are slugs rather than timestamps, and older sequential ids such as t-0001 or m-01 stay valid forever and are never renumbered.

On a machine with no Blink installed, the same id comes out of one line of Node:

node -e 'console.log((Math.floor(Date.now()/1000)-1767225600).toString(36).padStart(6,"0"))'

Prints the part after the type letter, so prefix it yourself.

The three structural rules

Three rules hold the format together. Two of them look like style and are not, so each one is here with its argument attached.

Frontmatter id is authoritative. An entity is identified by the id in its frontmatter, never by its path. A filename that disagrees is a warning and the file loads anyway. This is what makes renaming a file safe, and it is why a duplicate id in two files is an error while a wrong filename is not: two files claiming one id is genuinely ambiguous, a badly named file is not.

Forward references only. A task points at its milestone. A milestone never lists its tasks. A task names the risk blocking it; a risk never lists what it blocks. Every reverse index you see on the dashboard is computed on read.

This is the rule that shapes every other decision in the format, and it is a concurrency rule wearing the clothes of a style rule. Forward-only means adding a task, moving it or dropping it touches exactly one file, so two agents working in parallel never write the same file and a merge stays a merge. Invert it and every dispatch would mutate a milestone, which is a conflict manufactured in the one workflow where several writers are in flight at once. If you find yourself adding a task list to a milestone to make it more readable, that is the rule you are about to break.

A run is the one carve-out: it lists the tasks it covers. That is legitimate rather than an exception granted for convenience, because a run record is single-writer by construction. Exactly one orchestrator writes it while the run is on, so the contention the rule defends against cannot arise. The alternative, a run field on every task, would put the mutation back on the files the lanes are already writing. A task belongs to one milestone permanently, which is why that pointer lives on the task; a task appears in several runs across retries, and that sequence is history rather than a field to overwrite.

Fixed status enums. Each type has its own list of statuses and there are no per-project vocabularies. A status outside its type's list is an error, not a warning, and borrowing a value from another type is the same error: in_progress is a task status, and a milestone being worked on is active.

The status enum for each of the seven types

Progress is never written down

There is no percentage anywhere in the format, and no field you can set to one. Progress is computed on every read as done / (total - dropped), over the tasks pointing at a milestone for that milestone's bar and over every task for the project's. Blocked counts, open risks and who has what in flight are derived the same way.

Derived means it cannot go stale, which is the entire argument for not storing it. It also means the denominator is precise: dropped is the only status that leaves it. A paused or blocked task still counts against the percentage exactly like a planned one, because it is still work the project owes. Dropping work is how you tell Blink it is no longer owed, and nothing is deleted to do it: the file stays, with its history and its reason.

Entity types and statuses

Blink has seven entity types. The directory a file sits in is what decides its type, and each type carries its own fixed status vocabulary. There are no per-project vocabularies: a status outside its type's list is a validation error, and the file is reported with the reason rather than loaded.

The seven types, the directory that types them, and every status each one accepts.
TypeWhere it livesStatuses
Tasktasks/backlog planned in_progress blocked paused done dropped
Milestonemilestones/planned active done dropped
Decisiondecisions/proposed accepted superseded
Riskrisks/open mitigated accepted closed
Docdocs/draft active superseded
Runruns/planned running blocked done abandoned
Projectproject.mdactive paused done archived

Those directories sit inside the content directory, which is .blink until blink.json moves it. The tree, the filename rules and the id scheme are in the format. Every field each type carries is in every field, type by type.

The guess everybody gets wrong

in_progress is a task status and only a task status. A milestone being worked on is active. A run being worked on is running. A milestone carrying status: in_progress is not loaded with a warning. It fails its schema, which is an error, and it makes blink validate exit 1.

What is not true is that the vocabularies are disjoint. Several values appear in more than one list: done is legal on a task, a milestone, a run and a project, planned on a task, a milestone and a run, blocked on a task and a run, dropped on a task and a milestone, paused on a task and a project, active on a milestone, a doc and a project, accepted on a decision and a risk, and superseded on a decision and a doc. The value for work in flight is the one that never repeats, and it is the one people borrow.

How a task moves

The lifecycle runs backlog to planned to in_progress to done, with three exits. dropped is cancelled and terminal. blocked names an external dependency and requires blocked_by. paused is a voluntary hold and requires paused_reason.

The file is created at backlog while the work is still being planned, not when somebody starts it. It moves to planned once it is planned and queued.

Every legal task transition. A pair that is not in this table is a jump the lifecycle does not have.
FromCan become
backlogplanned dropped
plannedin_progress backlog dropped
in_progressdone blocked paused dropped
blockedin_progress paused dropped
pausedin_progress planned dropped
doneTerminal.
droppedTerminal.

Read the gaps as well as the rows. backlog cannot jump straight to in_progress, so a task is planned before it is started. blocked cannot go straight to done; it returns to in_progress first. paused cannot become blocked, and it is not terminal: it goes back to in_progress or to planned. Nothing leaves done or dropped, so reopening finished work is not a move the lifecycle has.

Note

The transition table is not what blink validate checks. Validation reads one file as it stands, so a task carrying any status in its enum passes however it got there. The lifecycle is checked by blink drift, which rebuilds each task's status sequence from git history and reports a jump the table does not allow. It reports it as a question rather than a verdict, because a squashed history can manufacture one.

dropped is not deletion

Nothing in Blink is ever deleted. Abandoned work becomes dropped, which keeps the file, its body and its git history. The board carries a column for every task status except that one, so the card leaves the board while the record stays in the repository.

dropped is also the only status the progress denominator leaves out, and progress is derived on every read rather than written down: see the format for how it is computed. A paused task is not dropped, so it stays in the denominator and holds the percentage down while it waits. Only dropping it takes it out, and dropping it says the work is cancelled.

archived, and where it is not a status

On a task, archived is a boolean field rather than a status. It hides the card from the board's default view, and progress still counts the task. Which statuses it is legal on, and what happens on the others, are in the task field table.

archived is also a project status, on project.md, where it means the whole project is shelved. The two are different things on different files, and neither implies the other.

The other six lifecycles

Only the task enum has a transition map. The other six are validated as enums and nothing enforces an order, so any value in the list is legal at any time. These are conventions the skills follow, not rules the validator applies.

  • Milestone: planned to active to done, with dropped as the exit. There is no in_progress here.
  • Decision: proposed to accepted. A decision a later one replaces becomes superseded and stays on disk, because the record of what was thought at the time is the point of the file.
  • Risk: open to mitigated, accepted or closed. accepted on a risk means you decided to live with it, which is not what it means on a decision.
  • Doc: draft to active to superseded.
  • Run: planned to running to done. blocked requires blocked_reason and not blocked_by, which a run does not have. abandoned is the run that was given up on.
  • Project: active, paused, done, archived, on the one project.md.

A run is the record of one execution of work already in the tracker. The executor and blink run write it, never you: hand editing one while it is open fights the process writing it. What a run records is in running work.

Every field, type by type

Frontmatter is state. Every field a Blink file can carry is below, by type, with what happens when it is absent, which is the half that is hard to guess. The statuses these tables refer to are in entity types and statuses, and the shape of the file itself is in the format.

Every entity carries these

The six fields shared by all seven types.
FieldTypeRequiredAbsent means
idstringyesThe file fails its schema. Unique within its type: two tasks cannot share an id, a task and a milestone can. project.md is the exception and defaults to project.
titlestringyesThe file fails its schema. Nothing falls back to the filename, and an empty string is not a title.
statusenumyesThe file fails its schema. The value has to come from that type's own list.
createdday or timestampnoNothing infers it, not from the file's mtime and not from git. Sorting by oldest puts the file last.
updatedday or timestampnoNothing infers it either, and recently updated sorts the file last in both directions. Bump it on every write.
tagslist of stringsnoAn empty list, so no tag filter ever selects the file.

Days and timestamps

created, updated and a project's started take either a YYYY-MM-DD day or a full ISO 8601 instant. On the instant form the zone is not optional, seconds are required and fractional seconds are allowed. A milestone's start, target and original_target, and a decision's date, are days and reject a time: a target is a day you are aiming at rather than a moment.

Dates have to be real, not merely the right shape. 2026-02-31 is rejected with or without a time on the end, an hour past 23 is rejected, and so is a leap second at :60, which ISO 8601 permits and no JavaScript date can represent.

yaml
updated: 2026-08-14T10:30:00Z       # accepted, and the form to write
updated: 2026-08-14T16:00:00+05:30  # accepted
updated: 2026-08-14                 # accepted, a day
updated: 2026-08-14T10:30:00        # accepted: unquoted YAML reads this as UTC
updated: "2026-08-14T10:30:00"      # rejected: a quoted string with no zone
updated: 2026-02-31                 # rejected: not a real day

The quoted line is the trap: quoting is what makes the zone missing rather than implied.

Tip

Write a timestamp rather than a day on created and updated. Agents touch a tracker several times an hour, and at day resolution a task created, started and finished before lunch records the same string three times. Sorting by recently updated then cannot order it, and blink drift cannot tell whether a commit came before or after the task describing it. Days stay valid forever, so nothing already written needs changing.

Task

Task fields, on top of the six every entity carries.
FieldTypeRequiredAbsent means
milestonemilestone idnoGrouped as no milestone rather than rejected. An id that resolves to nothing is grouped the same way and warns, so a typo loses the grouping and never the task.
ownerstringnoNobody owns it. The name is matched against discovered agents, and one that matches nothing still renders.
blocked_bylist of task or risk idswhen blockedAn empty list. On a blocked task an empty list is an error.
paused_reasonstringwhen pausedAbsent. On a paused task that is an error. Resuming clears it, and it is never carried forward.
archivedbooleannofalse. Only legal on a done or dropped task; anywhere else it is an error.
decisionslist of decision idsnoAn empty list. These are the decisions the task implements, pointed at by id.
docslist of doc idsnoAn empty list.
estimatestringnoSized as M when a work set is classified, which is the same weight an unrecognised estimate gets. See below.
ordernumbernoThe task sorts after every task that has one, with the id breaking ties. Any finite number is legal, negatives included.

The list fields are lists. blocked_by: t-0bj4qz fails, because a bare scalar is not an array, and the same is true of tags, decisions, docs and supersedes. order is a number, so order: "3" fails as well.

blocked_by resolves against task ids and risk ids and nothing else. Point it at a milestone or a doc and you get a warning rather than an error, so the task stays blocked by something nobody can find. decisions resolves against decisions, docs against docs, and every unresolved id is its own warning on the file that named it.

estimate is free text and the dashboard only displays it, but it is not inert. XS, S, M, L and XL are read, case and surrounding space ignored, and weigh 1, 2, 3, 5 and 8 when a work set is sized. Anything else weighs the same as M, and so does an absent estimate, so 2d is legal and lands on the default rather than on two days. What the weight then does is in running work.

Milestone

Milestone fields.
FieldTypeRequiredAbsent means
ordernumbernoThe milestone sorts after every milestone that has one, with the id breaking ties.
startdaynoThe Timeline bar has no start.
targetdaynoThe Timeline bar has no end, and nothing can slip.
original_targetdaynoNo slip is drawn. Set it once, the first time target moves, then leave it: it records where the plan started, not where it was last.
summarystringnoThe Overview shows the title on its own.

A milestone never lists its tasks. Tasks point at it, and every reverse index is computed, which is a concurrency rule rather than a style one: see the format.

Decision

Decision fields.
FieldTypeRequiredAbsent means
datedaynoUndated. This is the day the call was taken, which is not always the day the file was written.
supersedeslist of decision idsnoAn empty list. Decisions supersede decisions and never docs; an id that is not a decision warns.

The body carries the weight here, and the convention is ## Context, ## Options, ## Decision, ## Consequences. Nothing validates it.

Risk

Risk fields.
FieldTypeRequiredAbsent means
severitylow, medium or highyesThe file fails its schema. Severity is what orders risks on the Overview.

Body convention is ## Impact and ## Mitigation. Tasks point at risks through blocked_by; a risk never lists what it blocks.

Doc

Doc fields.
FieldTypeRequiredAbsent means
typebrainstorm, note, research or specyesThe file fails its schema.
supersedeslist of doc idsnoAn empty list. Docs supersede docs, never decisions.

A doc's id is a slug you choose rather than a generated id, and its filename is exactly that id with .md on the end. A slug after the id is the one filename that warns where every other type accepts it: the format has the rule.

Run

You do not write a run by hand. The fields are here because a reader debugging a half finished run opens one, and because the executor's record is the thing a fresh session resumes from.

Run fields.
FieldTypeRequiredAbsent means
tierinline, subagents, orchestrators or askyesThe file fails its schema. It shares the project's orchestration enum whole, ask included, because a run written while the tier was still being chosen is a state that happens.
taskslist of task idsnoAn empty list. These are the tasks the run covers, in dispatch order, and an id no task answers to is a warning.
gateslist of stringsnoAn empty list. These are the confirmed gate commands, in the order they run.
laneslist of lane rowsnoAn empty list. The row's own fields are below.
blocked_reasonstringwhen blockedAbsent. On a blocked run that is an error.

A run's blocked companion field is blocked_reason and not blocked_by. A run has no blocked_by at all: a lane is stopped by a gate or a failure rather than by another entity.

One row of a run's lanes list.
FieldTypeRequiredAbsent means
tasktask idyesThe file fails its schema. It also has to appear in this run's tasks.
worktreestringnoNo worktree, which is what the inline and subagent tiers look like. The path is absolute when it is there.
branchstringnoNo branch, for the same reason.
statepending, dispatched, ready, merged, failed or skippedyesThe file fails its schema.
attemptsnumberno0. It counts failed dispatches, and a lane that has not been dispatched has not failed.
wavenumberyesThe file fails its schema. It is never defaulted: a lane whose wave nobody wrote down cannot be scheduled, and quietly guessing 1 would run it first.

A lane naming a task the run's tasks does not list is an error rather than the warning every other dangling reference gets. Both lists live in one file written by one process, so a disagreement between them is a typo and not a reference into a tracker that legitimately moved on.

Project

Fields on project.md.
FieldTypeRequiredAbsent means
startedday or timestampnoThe project has no start date. Nothing infers one from the first commit.
orchestrationinline, subagents, orchestrators or asknoBehaves as ask. The field is a ceiling on how much machinery a run may use rather than a target, so it can only lower the tier a work set computes, and ask imposes no ceiling.

project.md carries a title and a status like everything else, and leaves id out: it defaults to project, and a file declaring any other id is a filename warning. The body is the project overview, and it is the first thing an agent reads.

What is never a field

Progress is derived on every read and never written into a file, and so are blocked counts, open risk counts and work in flight per agent. A milestone does not list its tasks, a risk does not list what it blocks, and a task does not name the run that dispatched it. Writing any of them down creates a second copy that is wrong the next time somebody edits the first. The format has the rule and the arithmetic.

This same reference ships into your repository. blink init writes it to SCHEMA.md inside the content directory, generated from the same enums the validator uses, so an agent or a teammate on a machine that never installed Blink still has the field list beside the code. Refreshing it is part of upgrading a project: installing and upgrading.

The skills

A skill is a markdown document that blink init writes into your repository, one directory per skill under .claude/skills/, each holding a SKILL.md. The directory name carries the prefix and the colon, so the tracking skill lives at .claude/skills/blink:tracking/SKILL.md. You use them by talking to your agent.

There is no board to maintain and no form to fill in. Nothing in the dashboard edits a file, so the place the work gets recorded is the same place the agent is already working: the skills are what put it there. See the dashboard for what the browser does instead.

These are Claude Code skills. They will not fire in another harness, because a slash command is that harness's mechanism rather than Blink's. What reaches every other agent is the contract itself: init writes a marked block into CLAUDE.md and into AGENTS.md, and the AGENTS.md block states the rules inline rather than pointing at a skill, precisely because a harness reading that file has no on-demand skill mechanism to defer to. What a different agent misses is the guided session, not the format.

Some of the skills are routers rather than procedures. /blink:project, /blink:design and /blink:execute each ship numbered reference pages in a references/ directory beside their SKILL.md, and each loads exactly one of them per invocation. That is why those directories hold more than one file, and it is a context budget rather than tidiness: an orchestrating session that reads three procedure bodies up front has spent the room the work itself needs.

/blink:project

Plan the work. Reach for it when you are starting something new, pointing an agent at a codebase that has no plan, finishing a milestone and needing the next one, or coming back after a while and wanting to know where things stand.

/blink:project plan

Bare /blink:project prints a status summary and the subcommand table, and writes nothing.

Subcommands, and what each one loads
SubcommandWhat it does
planPicks the next unstarted milestone by order and breaks it into tasks with you
milestonePlans a new milestone, or extends an existing one
agentsReviews the agent roster against how the project actually works and proposes the minimal change
statusA read-only report: milestones by status, open and done task counts. Zero writes, no questions
helpPrints the table above. Zero writes

With no subcommand it detects state and routes. An empty tracker in a directory with no source code gets the intake interview: at most ten questions, one at a time, covering purpose, users, the scope cut line, the stack, constraints, the quality bar, milestone shape and the orchestration default. An empty tracker in a repository that already has code gets a survey instead, and derives the same entity set from the repository rather than from you: the README, the package manifest, the source layout, the themes in git log --oneline -40, every TODO and FIXME in the source, and any tracking already sitting in the repo.

It reads before it asks, so it never asks what the repository already answers, and anything your invoking message already settled is skipped rather than confirmed twice. Questions arrive one at a time. Nothing is written until you have seen the whole proposed entity set as one table and said yes to it, and writes go in dependency order: risks and decisions before the milestones and tasks that point at them, because a reference written before its target turns a clean blink validate into a warning.

It ends with a confirmed task set in the tracker, each task at backlog or planned with its milestone named. That is the deliverable, and it is where the skill stops. It never dispatches a subagent, never creates a worktree and never starts a branch, whatever the fan out. The reason is worth the sentence: work dispatched from planning has no run record behind it, so nothing enforces the merge gates and a resume has no file to read.

One field is read here rather than at run time. The orchestration setting in project.md is a project-level preference, so planning asks for it once when it is absent and offers to write the answer back as the default. Planning will also describe the shape of the coming run in a sentence or two, and it is careful to frame that as an expectation rather than a plan, because the real waves, tier and gate list come out of blink plan inside /blink:execute. See running work.

The agents subcommand is roster only: it touches no task and no milestone, it never overwrites an agent somebody wrote by hand, and it retires an agent by noting the retirement in its body rather than by deleting the file. It records the resulting roster as a decision. What it is reviewing is described under how agents are discovered.

/blink:execute

Run the plan. Reach for it when the plan is agreed and the work needs doing: a milestone to run, a handful of tasks to dispatch, or a run that stopped half way and has to be picked up.

/blink:execute m-03

Or a list of task ids, or nothing at all to run whatever is next.

A run is a work set that is already in the tracker, worked out into a plan of waves and lanes, shown to you and stopped on, then dispatched, verified at every merge, and written into a run record a fresh session can resume from. The skill's own page is a router: three phases, one reference file each, opened when you reach that phase and not before.

Phase zero is the check nobody expects. If the tracker already holds a run at running, this invocation is a resume rather than a new plan, and it goes straight to reconciliation: re-read the run record, compare it against the worktrees and the lane branches, report what was found, and re-enter dispatch at the first wave that has not landed.

Tip

You do not have to check by hand. blink plan exits 3 when a run is live and prints no dispatch table at all, because a fresh plan printed beside a running run reads as the thing to do next and it is not.

Pre-flight derives the work set, the waves, the tier and the gate list, prints them, and stops. Nothing moves without an explicit yes, including when the classifier is confident, because a wrong tier costs the whole run rather than one lane. On your confirmation it dispatches the lanes, lands them one at a time with the gates run between each, and writes every step into the run record as it goes. Where those waves, tiers, lanes and gates come from is running work.

Things the skill refuses to become, each with the reason attached:

  • No planning. Free text that names nothing in the tracker is handed back to /blink:project, never turned into an invented task. A task the executor made up has no agreed scope and no owner, so nothing it produces can be reviewed against anything.
  • No scheduling. A run executes now, in this session. There is no queue, no cron and no detached background run, because a run nobody is watching cannot be stopped when its first wave comes back wrong.
  • No automatic conflict resolution. A conflict is resolved by the lane that caused it, inside its own worktree, by the agent that wrote the code. Resolving it in the orchestrating session is code work in the one thread that must not do code work.
  • No new agent types. It dispatches agents that already have definitions. Inventing a fifth role means dispatching an agent nothing defines.

Two rules hold across every phase and they are the ones that matter to anybody reading the tracker while a run is in flight. The orchestrator is the sole writer of the content directory and of the run record; lanes write their own task files and never the run file. And the orchestrator's context is treated as the scarce resource: it does not read implementation code, does not inline a lane's transcript and never holds the run record's contents, because the record on disk is the memory and a session that was summarised re-reads rather than recalls.

Nothing in Blink itself ever runs git worktree. The skill instructs an agent and the agent runs git, so every branch and worktree in the repository has a person or an agent behind it rather than a library.

/blink:tracking

The contract every agent follows, and the one skill you never type. It is written to fire on its own description: when work is planned, started, finished, blocked or dropped, when a real technical choice gets made, and before anybody reports progress.

Note

There is nothing to invoke here. A reader hunting for the command concludes the skill is not installed, and it is the one everything else depends on.

The contract is a short numbered list, and every status in it is from the task enum. It says so explicitly, because borrowing a task status onto another type is the common mistake: see entity types and statuses.

  • Planning work. Create the task file now at status: backlog pointing at its milestone, and move it to planned once the plan is settled. Never edit the milestone. Ids are generated rather than counted.
  • Starting work. Set status: in_progress, set owner to your agent name and bump updated, before the work rather than after it.
  • Finishing. status: done. Never delete a file: abandoned work becomes dropped, which keeps the history and leaves the progress denominator.
  • A real choice made. Write a decision file and link its id from the task that implements it. A choice worth explaining twice is worth writing once.
  • Stuck. status: blocked with blocked_by naming a risk id, and create the risk file first if it does not exist.
  • After any write. Run blink validate and fix anything it calls an error before moving on.
  • Pausing. status: paused with paused_reason, and prefer the two skills below to hand editing those fields.
  • Parallel work. Anything beyond a single task goes through /blink:execute, which picks the tier, writes the run record and enforces the gates. A single task you just do.

Two details in it are easy to skim and are the reason the tracker stays orderable. Dates take a full timestamp with a zone rather than a bare calendar day: a bare YYYY-MM-DD validates and always will, but a task created, started and finished before lunch writes the same string three times and then nothing can put the day in order. And computed numbers are never written into a file, because progress and every count are derived on read.

It also says what not to do, and the sharpest one is not to open a task at in_progress for work that is already finished. If the file did not exist while the work was happening, the honest move is to say so in the body rather than to backdate it.

/blink:sync

Put the board back in step with reality. Reach for it when a session died mid task, when somebody shipped work without touching the tracker, when a run stopped half way, or when the statuses just look wrong.

/blink:sync

Say it in words: "did we forget to close anything".

It is read only until you say otherwise. The evidence pass is one command, blink drift . --json, which reconciles three records that are otherwise never compared: what each file declares, what git history says happened to that file, and everything the validator knows is structurally wrong. See the CLI for the command itself.

Tip

blink drift and /blink:sync are not two names for one thing. drift reports and never writes. The skill is what turns that report into repairs, and only the ones you confirm.

One rule shapes the whole procedure: there are two kinds of finding and they are never printed as one list. An established finding is two records that both exist and contradict each other, such as a task file whose creating commit already said done. A finding that needs a human is an observation that suggests drift and cannot settle it, such as a task sitting at in_progress for six weeks, which may have shipped, stalled or been abandoned. Merging the two produces a ranked list of equally confident claims, which is how a report gets ignored, so the skill carries the confidence each finding already has and never promotes a question because it looks obvious.

It prints the two tables, says what it will not touch, and then asks once with both tables on screen. The established repairs are one answer and each open question is its own; it does not walk the list file by file. Findings that share a group are one event seen once per file and are collapsed into a single row and repaired once.

Two things it must never do, and both are about honesty rather than caution:

  • Never rewrite history to tidy the board. A file written late is a fact about how the work went. created is never moved backwards, and a task recorded after the fact keeps its dates and gains a line in its body saying so. That line is the entire repair.
  • Never guess. Anything in the question table stays there until a person answers it. A tracker that quietly closed the wrong task is worse than one that is visibly behind.

Repairs stay inside the content directory, no file is deleted, and no date moves backwards. A stalled run is the one finding it will not repair at all: a run that is still going and a run whose orchestrator died look identical from a drift report, and finishing a live one strands whatever its lanes hold, so it offers /blink:execute first and only closes the run if you say it is genuinely dead. It finishes by running the validator, printing what changed and what was deliberately left alone, and offering to write anything still open into the tracker as a task, because reconciling the tracker is real work.

/blink:setup

Align a project to Blink: check what is already true, report it, and write the difference only after you confirm. Reach for it when the work is already tracked somewhere else, a PLAN.md, a folder of ADRs, a pile of TODOs, notes in the README. It is also the last step of every upgrade, after the package and the scaffold, and that is where most people meet it.

/blink:setup

Also answers "is blink wired up properly".

Where it fits in an upgrade

Tip

The name undersells it. It audits a project that is already wired up and tells you what is out of date, and it is the fastest way to find out what tracking is in a repository you inherited.

It stops before anything if there is no blink.json at the project root, and says that blink init . comes first. It aligns a project to a tracker; it does not create one. When the file is there it reads contentDir from it rather than assuming .blink.

The survey writes nothing and answers every one of these:

  • Whether blink.json parses and the content directory it names exists with its entity subdirectories.
  • What blink validate says, recorded as counts plus the first few messages verbatim.
  • Whether CLAUDE.md and AGENTS.md exist and carry a blink:begin block. A file that exists without the block is the common case and the one that matters: the contract is installed but nothing loads it.
  • Whether a block that is present is current, by its version stamp and by its body. A block predating the current task lifecycle is confidently wrong rather than merely old.
  • Every skill, for presence and for currency. It reads the blink_version field out of each SKILL.md frontmatter, so a present but stale skill is its own finding rather than a pass.
  • Installed capability the project has not adopted, reported only and never acted on.
  • Blink's own older output, meaning the un-namespaced .claude/skills/blink-tracking/ directory that predates the colon prefix.
  • Whether any agents are defined at all.
  • Competing tracking: a project state file, phase files whose checkboxes are statuses, ADRs, a debt list whose rows carry severities, TODO.md, BACKLOG.md, plans and specs under docs/.

It reports as one table, and the kind column is the part that carries meaning. Missing and stale are Blink not being wired up correctly. Legacy is Blink's own older output that should not still be there. Competing is a system somebody built on purpose, holding work that has to survive. Unused capability is a suggestion with no action attached. Filing a legacy row under competing is called out as an error, because it invites the answer that somebody still uses that file, which is the wrong answer for a file Blink itself wrote.

Then it asks once, with the whole table on screen, and applies only what you agreed. Conversion follows one rule: convert the work, keep the file, remove the competition. Real work becomes an entity, an unchecked item becomes a backlog task and a checked one becomes done, a debt row becomes a risk carrying its own severity, an ADR becomes a decision keeping its original prose. Written down percentages and counts are dropped rather than copied, because progress is derived. The original file is never deleted: it is rewritten into a short pointer saying where the plan lives now and when it moved, so anybody landing on the old path is redirected instead of reading a stale list. Ids continue the existing sequence.

The single thing it will ever remove is that legacy blink-tracking directory, and only under three conditions: the replacement skill is really on disk and says so in its own frontmatter, git history shows one commit and a clean status for the old file so nobody has edited it, and the directory holds nothing else. If any condition fails it says which and leaves the directory alone. Discarding somebody's local edit to save them one stale file is a bad trade.

It is also where an agent roster comes from, when there is not one already. That is described under how agents are discovered.

/blink:pause

Put one or more tasks on hold with the reason recorded. Reach for it when something is deprioritised, waiting on a person, or shelved until after a release.

/blink:pause t-0042

Takes several arguments, and an argument can be a title substring: "park the auth work".

Each argument is resolved as an exact task id first, then as a title substring. Exactly one match is used; more than one gets you a list of candidates and a question, and it never guesses. An argument that matches nothing is reported and the skill stops.

Only a task at in_progress or blocked can enter paused. Anything else is skipped with a note rather than forced. If your message carried no reason it asks for one before writing anything, because paused_reason is required by validation and a task paused without context cannot be resumed by anybody else. It then writes three fields, status, paused_reason and updated, touches nothing else because the body prose is yours, prints one line per task and runs the validator.

Note

Paused is not blocked. blocked names an external dependency and requires blocked_by; paused is a voluntary hold and requires paused_reason. A paused task also stays open work: it is still counted, it is still compared against git, and only dropped leaves the progress denominator. What pausing buys is that the work stays visible with its reason attached instead of going quiet.

/blink:resume

Bring paused work back, and clear the reason on the way.

/blink:resume t-0042

Same lookup as pause, so "unpark the auth work" resolves too.

Only a task at paused can be resumed. It goes back to in_progress by default, and to planned when you say the work is not starting yet. paused_reason is removed entirely rather than left behind, because it belongs to the paused state and a stale reason on a live task is a lie the next reader believes. Name an owner and it sets one; say nothing and the existing owner stays as it is. Then it validates.

/blink:design

Decide how it should look, once, and then build against that decision. Reach for it when you are building UI and want something considered rather than defaulted.

/blink:design

Say what you want: "how should this look", "build a prototype".

It routes on one condition: whether a design doc already exists in the content directory. If it does not, the first invocation runs the context interview. If it does, every later invocation goes to the prototype loop, reads the doc back and picks up where the last round ended.

The interview reads the project file, the existing docs and the existing decisions before it asks anything, then asks only the gaps: audience, brand and colour references, typography, style tone, target devices, and the accessibility bar. One question at a time. It writes a design spec with fixed sections for palette, typography, devices and accessibility, one decision file per commitment that would cost real effort to reverse, and a design milestone with a task per round of work. An open range is not a commitment and does not become a decision.

The prototype loop presents a page inventory and stops before scaffolding anything, because renaming a page before a file exists is free. Then it scaffolds into the project's own stack if the project has a dev script, and into plain HTML and CSS under a prototype/ directory if it does not. It serves that on a local port, prints the URL, and kills the server when the session ends. Displayed data lives in fixture files and is updated in the same commit as the UI change that renders it, and each user gated round is its own commit. If a dedicated hi-fi design skill is installed it hands the visual pass over rather than duplicating it.

Two rules never change. Visual judgment is gated on a person looking at it in a browser every round, and automated approval does not exist. And the design context is asked once and read back forever: re-asking questions the design doc already answers is the single failure this skill is built to prevent.

It is tracked work like any other. The design doc, the decisions, the milestone and its tasks are ordinary entities, and a round that ships is a task moving to done. What does not appear anywhere in Blink is the prototype itself: that is a local server and a directory of files, and the dashboard neither serves it nor knows it exists.

How agents are discovered

An agent is a definition file sitting in your repository. Blink finds it by reading the filesystem every time it builds a snapshot, so an agent appears because it exists rather than because anybody registered it. There is no roster file, no registration command and nothing to keep in step.

Where agent definitions come from, and what each source yields
SourceRead asYields
.claude/agents/*.mdsubagentOne agent per file. name, description, model and tools come from its frontmatter
.claude/skills/*/SKILL.mdskillOne agent per skill directory. Informational, and never a task owner
AGENTS.mdagents-mdA single agent named codex, if the file can be read at all. It stands for Codex and any other harness reading that file
~/.claude/agents/*.mdsubagent, globalThe same per-file read, marked global. Always scanned, and not part of any project setting
implicitsessionThe default session, named claude. Always present, always first, and the owner of anything not explicitly delegated

The first three are the default value of agents.scan in blink.json. Setting that key replaces the default list rather than adding to it, so a project that names one directory of its own gets that directory and nothing else. The home directory scan is separate and is not affected either way. See configuring a project for the key itself.

How an entry is read is decided by the entry's own name, not by what is inside it. An entry ending in .md is treated as an AGENTS.md and produces the single codex agent. An entry ending in skills is read as a directory of skill directories, each contributing its SKILL.md. Anything else is read as a directory of agent files, one agent per .md directly inside it, in filename order. Subdirectories are not walked, and a skill directory with no SKILL.md contributes nothing.

Frontmatter is read where it exists and nothing is required. A missing or unparsable frontmatter block still yields an agent, named after the file with its extension dropped, or after the directory for a skill. tools accepts either a YAML list or one comma separated string, because both forms appear in the wild.

Names are unique and the first definition of a name wins. The order is fixed: the session agent, then each agents.scan entry in the order blink.json lists them, then the home directory. So a project agent shadows a global one of the same name, and nothing can displace the built in claude.

Scanning is bounded to the project. An agents.scan entry that resolves outside the project root is skipped, and the check is applied both to the written path and to what it resolves to, so a symlink inside the repository pointing somewhere else is skipped too. That is why a symlinked agents directory silently yields nothing. The home directory scan is the one intended source outside the project, and it is handled separately for exactly that reason.

Adding your own

Write a file. An agent definition is .claude/agents/<name>.md with name, description and tools in its frontmatter and a body saying what the agent does and what it must not do. It is discovered on the next snapshot, which is to say immediately, with the dashboard open. Attach work to it by setting a task's owner to that name.

blink init writes no agent files, on purpose: a roster is a claim about how a team works, and a scaffold has no business making one. Two skills will, and both ask first. /blink:setup offers a starter roster when the directory is empty, and leaves an existing roster entirely alone. /blink:project agents reviews a roster that exists and proposes the minimal change. Both are described in the skills.

That roster is worth reading even if you write your own, because the shape of it is the argument. Tool sets are the constraint, not the description:

  • blink-orchestrator owns the task lifecycle and dispatch, and has no write or edit tool, so its plan is carried out by someone whose job that is.
  • blink-core-builder and blink-ui-builder write code and have no agent tool, because a builder that can spawn builders makes the roster meaningless.
  • blink-reviewer runs the gates and reads the diff, and is read only, so a finding is reported rather than quietly patched.
  • blink-tracker-scribe authors decisions and risks, scoped to the content directory, and creates a risk file before anything points at it.

Owners, matched and unmatched

A task's owner is matched against discovered agents by name, exactly. Case is not folded, because folding it would hide the typo the match exists to reveal.

Both halves of a mismatch stay visible, and both are deliberate. An owner that matches nothing still gets a card, saying which owner string appears on how many tasks and that it matches no discovered agent, so a human name and a typo both surface instead of vanishing. An agent that owns nothing gets a card too, reading that it is defined with no tasks assigned yet, so idle capability is visible rather than invisible. A skill with no tasks is the expected state and says so instead.

Tip

A skill is never a task owner, whatever it is called. Naming a task's owner after one produces an unmatched card rather than attaching the work to it.

How these are grouped, counted and filtered on screen is in the dashboard.

Why the contract lives in your repository

Discovery finds an agent. What tells it how to work here is a separate thing, and it is written into the repository rather than held inside the Blink install. blink init writes the skills under .claude/skills/, the field reference as SCHEMA.md inside the content directory, and a marked block into CLAUDE.md and AGENTS.md.

So an agent on a machine that has never installed Blink still writes the format correctly. It is not following a tool; it is reading the instructions sitting next to the code. The AGENTS.md block is longer than the CLAUDE.md one for that reason: a harness reading it has no on-demand skill to defer to, so the block states the lifecycle, the two conditional fields and the fixed enums inline.

It surveys before it writes anything, so it is also how you find out whether a checkout is current without changing it. Every skill and instruction block on disk comes back as present, missing or stale.

Running work: tiers, waves, lanes and gates

A run is one execution of work that is already in the tracker. Three commands do the mechanical parts of it. blink gates finds what green means in this project, blink plan turns an invocation into a proposed plan, and blink run writes the record. This section is that mechanism. The skill that sits on top of it is described with the other skills.

Nothing here dispatches on its own. The plan is a proposal every time, including when the classifier is confident, and confirming it is a separate step taken by a person.

From an invocation to a work set

Execution starts by resolving what you asked for into a set of tasks. That resolution is a pure read of the tracker. It writes nothing and it validates nothing, so an unrelated warning elsewhere in the project cannot block a run.

How an invocation resolves
You nameWhat resolves
Task idsThose tasks
m-04, a milestone idEvery task filed under that milestone
nextThe lowest-order milestone at planned or active that still has a runnable task, and its tasks
Nothing at allnext, applied as the default. The plan says so rather than pretending you asked
Free textNothing. It is handed back to /blink:project, never turned into a task

Ids match case insensitively, and backticks, quotes, commas and square brackets are shaved off both ends of a token first. An id pasted straight out of a task body as [[t-0042]] resolves. Naming a task and a milestone in one invocation unions the two, and a task reached both ways appears once.

A task that is already done or dropped leaves the set and is listed as excluded rather than quietly removed. A token that is shaped like an id but names nothing here is reported separately from free text, because the first is a typo about the tracker and the second was never a claim about it.

next skips a milestone whose tasks are all finished even when its status still reads active. Milestone status is set by hand and lags reality constantly, so picking on status alone would resolve to an empty set while the next milestone sat there full of work.

Tip

Free text that names nothing in the tracker never becomes a task. It resolves to nothing at all, and the answer is to plan it with /blink:project first. A run over an invented task has no agreed scope, so there is nothing for the result to be checked against.

Waves are derived from blocked_by

A wave is a maximal set of tasks in the work set with no unmet dependency inside the set. Wave 1 is every task whose in-set blockers are empty. Wave 2 is whatever wave 1 unblocks. The layout falls out of blocked_by and nothing else. It reads no task bodies and guesses at nothing.

Only a blocked_by entry naming a task that is also in the set creates an edge. That field may legally name a risk, or a task in another milestone, and neither of those can order a set it is not a member of.

Five tasks, two edges
t-01  blocked_by: []
t-02  blocked_by: []
t-03  blocked_by: [t-01]
t-04  blocked_by: []
t-05  blocked_by: [t-03]
The waves those five tasks lay out as
WaveTasksWhy
1t-01, t-02, t-04Nothing in the set blocks them
2t-03Waited for t-01
3t-05Waited for t-03

Three waves, and the widest is three. Two edges produced two barriers. Within a wave, tasks are ordered by order ascending, tasks without one last, ties broken by id. The whole layout is a pure function of the frontmatter, so the same tracker gives you the same plan on a second look.

  • A blocker outside the work set does not hold its task back. A task blocked by t-99 still lands in wave 1 when t-99 is not in the set. The plan reports it as an external blocker so you can see it and decide, and the layout does not move.
  • Two tasks that block each other cannot be scheduled at all. They are reported as a cycle and appended as one final wave rather than dropped, because a plan that quietly runs most of the work is worse than one that names the three tasks whose blocked_by needs fixing.
  • A task listing itself is a cycle of one and is reported as one, rather than being ignored as harmless.
  • A task listing the same blocker twice is one edge, not two.

A wave is a barrier, not advice. Wave N+1 does not begin until every lane of wave N is merged, failed or skipped. ready does not clear it, because ready means the lane is finished and still waiting to be landed. The reason is where the waves came from: a task in wave N+1 declared a dependency on one in wave N, so starting it early means writing against code that is not there yet.

A fact and a guess are argued with differently

Pre-flight labels every line of its reasoning with where that line came from. There are two labels, and the difference between them is the thing readers most often miss.

The two provenance labels, and where a correction goes
LabelComes fromTo argue with it
derived from blocked_byDeclared frontmatter: blocked_by, order, estimateEdit the frontmatter and re-run blink plan. Nothing else moves it
guessed from tags and task bodiesPaths named in a task body, plus tags mapped to source rootsSay so. It is free and it needs no edit anywhere

The wave layout is a fact. Four waves is not an opinion the planner is holding, it is a consequence of what the task files declare. Overruling it in conversation changes nothing on disk and leaves the tracker permanently stating something other than what ran.

File overlap is a guess. Nothing in the tracker declares which files a task will touch, so a footprint is inferred from paths named in the body and from tags mapped to source roots. The inference never touches the filesystem and never checks that a path it inferred exists, which is deliberate: planned work usually names files nobody has written yet. A wrong guess costs one sentence to correct.

Render the two the same way and people go and fix the wrong thing. They argue with a fact in conversation, where it does not stick, and they edit frontmatter to correct a guess that never needed an edit.

The three tiers

What each tier does differently
TierWhere the work runsWhat a lane gets
inlineThe invoking session does the work itselfNothing. There is no fan-out to isolate
subagentsSeveral subagents, one checkoutNo worktree and no branch. The gain is context: the work stays out of the invoking conversation
orchestratorsOne git worktree per laneA worktree at .claude/worktrees/<task-id> on branch lane/<task-id>

ask is the fourth value of orchestration and it is not a fourth tier. It is the absence of a declared answer, which is why blink plan --tier refuses it: forcing it would name no tier at all. A run record may still carry it, because a run whose tier was still being chosen when the file was written is a state that happens.

A worktree is filesystem isolation between concurrent writers. It is not a reward for related work, and this is the table people read backwards. Two tasks that touch the same file in different waves never write at the same moment, so their shared file is a note about merge order. Genuinely disjoint work is cheaper flat, in one checkout, where there is no merge tax at all.

How the tier is chosen

The rules run in order and the first match wins. Each one carries its own basis, so the reason printed beside the tier tells you whether arguing with it means editing a file.

The tier ladder, first match wins
When the set looks likeTierBasis
Nothing resolvedinlineFact
One taskinlineFact. True however large the task is: a worktree would isolate it from nobody
Every wave holds exactly one tasksubagentsFact. No two writers ever exist at once, so every worktree is pure merge tax
Two tasks sharing a wave look like they touch the same filesorchestratorsGuess. The only rule on this ladder that is not a fact, and it says so on screen
Two or more tasks share a wave and are estimated L or XLorchestratorsFact. Independent long-running lanes
Anything elsesubagentsFact. Several tasks, none long, none writing the same file at once

Only overlap within a wave escalates. A pair split across two waves stays in the plan, because it is worth knowing when you choose a merge order, and it does not move the tier. A long task alone in its wave does not escalate either, for the same reason: it is the only thing running.

estimate is a free-form string, so this is a convention rather than an enum. XS, S, M, L and XL are recognised, with surrounding space and case ignored. Long means L or larger. An absent or unrecognised estimate weighs the same as M rather than nothing, because a set of nine unestimated tasks reading as trivially small is the failure that costs a run.

orchestration in project.md is a ceiling. It can lower the computed tier and it can never raise it.

Note

This is the one people have backwards. Declaring orchestration: orchestrators does not put a single task into a worktree, and it will not lift a serial chain above subagents. The field says how much machinery this project tolerates, not how much it wants. orchestration: inline does suppress fan-out entirely, because a ceiling of inline sits below everything. ask and an absent value impose no ceiling at all, so the computed tier simply stands.

A tier named in the invoking message beats both the computed tier and the project ceiling, and it is the only thing that can raise as well as lower. Use it by re-running the plan rather than by agreeing to something in conversation, so the plan that gets recorded is the plan somebody saw.

blink plan m-04 --tier subagents

inline, subagents or orchestrators. ask is rejected.

When either of those moves the answer, the plan prints the tier it computed, the tier it will run at, and which of the two moved it. An override that changed nothing is not reported as an override.

Gates are detected, not configured

blink gates

Takes a path and defaults to the current directory.

It prints the commands that constitute green in this project, in the order they should run. Nothing about Blink's own toolchain is assumed. Every answer comes from a file sitting in the project root, and it only ever reads: it never runs a gate and never decides whether one would pass.

Each category is resolved on its own, with several sources tried in turn and the first hit winning. A repo with a typecheck script and a Makefile test: target gets one gate from each.

Where each gate category is looked for, in order
CategorySources, first hit wins
typecheckscripts.typecheck, scripts.type-check or scripts.tsc, then a typecheck or type-check Makefile target
lintscripts.lint, then a lint Makefile target
testscripts.test, then a test Makefile target, then an ecosystem marker: Cargo.toml, go.mod, pyproject.toml, setup.py, setup.cfg or mix.exs
buildscripts.build, then a build Makefile target
validateblink validate, added when the project's content directory is really there

Only the test category falls through to an ecosystem marker. A language's conventional test runner is a safe guess; its build and lint commands are not.

The package manager comes from the lockfile: pnpm-lock.yaml, yarn.lock, bun.lockb or bun.lock, and npm when none of them is present. Two spellings are on purpose. npm test is the idiom for npm's test script, and bun always gets an explicit bun run test, because a bare bun test runs bun's own runner instead of the script you declared.

Makefile detection is line anchored, so name: and name:: count while a prerequisite in .PHONY: test does not, and the assignment forms are rejected so that a variable called test never becomes a make test that does nothing. Makefile, makefile and GNUmakefile are all looked for.

Making your own gate visible to detection means giving it one of those names. A script called check is not found, because a name nobody agreed on cannot be guessed. Rename it, add an alias under a conventional name, or edit the gate list at pre-flight. The list is a proposal from your files, never authority, and a wrong line costs one edit.

Note

blink validate is kept out of the count of gates that exercise the code, on purpose. It checks the tracker's own files and not one line of your change. A project whose only gate is blink validate will happily report a green run that verified nothing, which is worse than reporting no gates at all: the first reads as evidence and the second reads as a question. blink gates exits non-zero in exactly that case, and the plan carries the warning so it lands inside the confirmation rather than in a footnote under it.

blink plan, before anything moves

blink plan m-04

Task ids, a milestone id, next, or nothing at all.

It resolves the invocation, classifies it, proposes the gates and prints the pre-flight screen. It writes nothing: no run record, no id, no tracker file. The screen says run (unwritten) rather than inventing an id, because a plan that looked recorded is a plan the next session would try to resume.

The screen carries the run line, the gate list, the wave table with one row per task, anything excluded or unresolved, and the provenance block with its fact and guess labels. It is meant to be shown as printed. A hand-copied plan and the recorded one differ exactly where it matters, which is a dropped row or a quietly reordered gate.

--json prints the same object for a caller to reason over, and it deliberately carries no task bodies. A task travels as its id, its title, its wave and the path to its file, and the lane opens the file itself. That projection is what stops a nine-task plan from costing nine task files of somebody's context.

The three outcomes of a plan
OutcomeMeansWhat a caller does next
dispatchA plan with tasks in it, ready to confirmShow it and stop for an explicit confirmation
handoffNothing resolved that could be dispatchedGo to /blink:project. Do not invent a task
resumeA run is already runningReconcile that run instead of planning a second one

Resume is decided before a fresh plan is built, so it wins even when nothing would have resolved anyway. No dispatch table is printed beside a live run, because a plan sitting next to one reads as the thing to do next and it is not.

The exit code each of those three outcomes returns

Lanes and worktrees

A lane is one task's dispatch. One lane per task, one row per lane in the run record, and that row is where the lane's state lives. Lanes in the same wave are dispatched together, in one message, so that they genuinely run at the same time. A wave dispatched one agent at a time is a serial run wearing a wave's clothes.

Lane states
StateMeans
pendingIn the confirmed layout, not dispatched yet
dispatchedAn agent is working it
readyThe lane says it is finished and is waiting to be landed. This does not clear the wave barrier
mergedLanded on the primary branch, with the gates green there rather than on the lane
failedGiven up on. Two failed dispatches stop a lane and the wave carries on without it
skippedNot run

At the orchestrators tier every lane gets its own worktree and its own branch, and lanes are landed one at a time with the confirmed gates run between each. Merging a whole wave and then running the gates makes one lane's red everybody's problem: four lanes in, a failing test tells you something broke and nothing about what.

A lane merges the primary branch into itself first, resolves any conflict there, and re-runs its own gates before it says ready. A lane that conflicts on the way out did not integrate on the way in. A task moves to done only once its lane is merged and the gates are green on the primary branch, because a lane's own green is evidence about the lane and not about the branch it landed on.

Blink reads worktrees and never writes them. It does not create, merge or prune one, so nothing in the tool moves your git state. Creating and removing a lane's worktree is your agent's work, and it is done in that order: a worktree removed before its merge is committed takes the commits with it.

Every lane's tracker is folded into one board, so parallel work is visible while it happens rather than after it merges. When two checkouts hold the same entity, the copy furthest along the task status ladder wins, then the more recently updated one, then the primary checkout. The tie-break is the lane's path, so the winner never depends on scan order.

How a folded board marks a lane's copy, and the Worktrees view

The run record

A run is the record of one execution of work already in the tracker. It is written by the executor and by blink run, never by hand.

.blink/runs/x-0f3k2p-the-export-pipeline.md
---
id: x-0f3k2p
title: 'm-04 the export pipeline'
status: running
tier: orchestrators
created: '2026-05-04T09:12:00Z'
updated: '2026-05-04T11:40:18Z'
tags: []
tasks:
  - t-31
  - t-32
gates:
  - npm run typecheck
  - npm test
  - blink validate
lanes:
  - task: t-31
    worktree: /repo/.claude/worktrees/t-31
    branch: lane/t-31
    state: merged
    attempts: 0
    wave: 1
  - task: t-32
    state: dispatched
    attempts: 1
    wave: 2
---

## Log

- 2026-05-04T09:14:02Z — t-31: dispatched on lane/t-31
- 2026-05-04T10:58:31Z — t-31 merged, gates green on main

The frontmatter holds the tier the run was confirmed at, the tasks it covers in dispatch order, the gate commands as confirmed, and one row per lane. A lane row carries its state, its wave and its attempt count, plus a worktree and a branch at the orchestrators tier and neither of those at the other two. A run at blocked has to say why. The body is the confirmed pre-flight plan followed by an append-only log, one line per event.

The record carries the plan that was confirmed and not the one that was proposed. A task somebody dropped is not in tasks. An edited gate list goes in edited. What is in the file is what a later session will believe.

A lane naming a task the run does not cover is a validation error rather than a warning. Both lists are written by one writer into one file, so a mismatch is the file disagreeing with itself rather than a reference into a tracker that moved on.

The blink run subcommands each print one line rather than the file, so an orchestrator can keep the ledger without holding it in context. blink run new prints the id of the record it created and the others take that id. blink run show is the one read, and it prints a derived summary rather than the file or the body.

The run subcommands and their flags

A run is the one carve-out from the forward references rule. Everywhere else a task points at its milestone and a milestone never lists its tasks, so adding or moving a task touches exactly one file and two writers never contend. A run does list the tasks it covers, and that is safe for the same reason the rule exists: exactly one process ever writes a run.

Forward references, and the two other structural rules

Resuming a run

A run that stopped is resumed, not restarted. Invoking the execute skill while a run is still running picks that run up, and blink plan refuses to propose a fresh one and names the run that is live instead. That is the point of the record being on disk: a session that fills up or dies takes nothing with it.

Tip

Expecting a second invocation to start a second run is how a project ends up with two ledgers over the same tasks, and two lanes writing one file. If the previous run really is over, close it with blink run finish before starting another.

Recovery is from files, never from conversation. That holds even when the same session started the run and remembers doing it, because a session that recalls a lane path instead of re-reading it has already drifted, and the drift is silent: the recollection is confident, specific and wrong about one field.

blink run show x-0f3k2p --json

Intent. Which tasks, which gates, which wave, and each lane's last recorded state.

Four reads, and they contribute different things. The record is intent. blink validate says whether the tracker is coherent enough to act on, since reconciling against one that does not parse produces confident nonsense. git worktree list and git branch --list "lane/*" are reality, and they are two separate signals: a branch can outlive the worktree that made it. Where the record and git disagree about git, git wins.

The failure worth looking for by name is a lane with commits on its branch and nothing recorded against it. The record says pending, the branch says otherwise, and redispatching it duplicates work that is already done, on a branch nobody looks at again. An empty commit range is the proof that a merge happened; the record alone is not.

The Worktrees view, which surfaces a lane's commits ahead and its last write

The dashboard, view by view

The dashboard is a local web server and a web UI over the same markdown the CLI reads. It watches each registered project's content directory and pushes what changed over a WebSocket, so a file your agent writes appears without a reload. It is dark only: there is no light theme and no theme switch.

Note

Nothing in the browser writes a file. There is no edit control on any screen, at any privilege, with or without a password. The pages below are drawings of your repository, and the repository is written by you and by your agents. The same promise covers git: Blink reads worktrees and never creates, merges or prunes one, so the prune hints on the Worktrees view are sentences to copy into a terminal rather than buttons. blink init is the only command that writes into your project.

Starting the server

Two commands reach the dashboard and they differ in what happens to your terminal. blink open reuses a server if one is already running and otherwise becomes that server, staying in the foreground until Ctrl+C. blink start runs the server in the background and hands the terminal back; blink start --open does both. Either way the browser lands on the bare URL, which is the cross-project page and not any one project.

blink open

Serves http://127.0.0.1:7777 and stays in the foreground. BLINK_NO_OPEN=1 skips the browser and serves anyway.

  • The bind address is 127.0.0.1 and is not configurable. What that does and does not protect is its own section.
  • The port is 7777 and it walks upward when that is taken, trying twenty ports before giving up. So the URL you memorised may not be the one serving today. blink status prints the port, the pid and the uptime.
  • Exactly one server runs at a time, and a second blink start reports the running one rather than failing. blink restart works from either state and blink stop exits 0 when there was nothing to stop.
  • One server serves every registered project. Opening it from inside a project does not scope it to that project. The registry decides what is on screen, and blink add <path> is what puts a project in the registry.

The server reads; it does not run your agents. Nothing on any screen dispatches work, retries a lane or cancels a run. Those three belong to the executor, and the dashboard is where you watch what it does.

The shell: rail, sidebar and footer

Every screen sits in the same frame. The far-left rail is one avatar per registered project in the project's own colour, with the current one ringed; the button at its head opens the cross-project page and the bell beside that opens notifications. A project with parallel work carries a lane count on its avatar, so two agents mid-task in a project you are not looking at are visible without opening it. A project whose blink.json has vanished is drawn greyed and stays clickable: it opens a panel naming the path it used to be at and telling you to restore it or run blink rm.

The column beside the rail is the nav, and it is two different navs over two different things. Inside a project it lists that project's screens under its name and path. On the cross-project page it is headed All projects and lists band anchors, the density control and a row per project with an eye that hides it. Both collapse to a 52px strip of icons, and both end in the same footer: a dot for the connection state, the words watching · synced 2m, and the version of Blink you are running.

Alt and a digit jumps to the first nine projects in rail order. On a Mac that is ⌥1 through ⌥9.

All projects: the three bands

The rail's first button opens the page above projects, and it is the URL with no parameters at all. It is one scroll with three bands stacked on it, and the sidebar's top group is anchors that scroll to them rather than tabs that swap them:

The three bands of the cross-project page, in scroll order
BandAnswersWhat is in it
FleetWhere does everything standOne card per registered project: its mark, name and status, a progress bar, the doing / pending / blocked / open-risks counts, the current milestone with its own bar, and real task rows. Cards are ordered by how much they want a look, never alphabetically
PulseWhat is happening right nowEvery run at running across the fleet with its lanes as a strip, every non-primary worktree checked out, and a since-you-last-looked feed of the events that land: milestones delivered, runs finished, waves completed, gates failed
AttentionWhat wants a humanNeeds attention, Lanes, In progress right now, Pending and Milestone progress, in that order. It is a list of work with a project chip on every row, rather than a list of projects with work inside them

The sidebar's second group is Density, and it decides how many bands are on the page: Everything is all three and is the default, Live is Pulse alone, Attention is the Attention band alone. It rides the tab parameter, so a narrowed page is a link you can send.

  • A band with nothing in it collapses to one sentence rather than to a hole. Nothing in flight · no lanes is a line a reader can finish; an empty region is not. The heading stays either way, because it is what an anchor points at.
  • Pending and Milestone progress start folded, and every Attention section states its count while folded. A fold never hides the number it was hiding rows for.
  • The eye on a sidebar project row hides that project from every figure on the page, and hidden rows are excluded from every total rather than merely from the grid. The row itself stays, because at the Attention density it is the only place a hidden project can be brought back from.
  • A figure the page cannot vouch for yet is drawn as a grey pill and not as a zero. Mid-load, a sum over the snapshots that happen to have arrived is a different number rather than a smaller one, and a 0 on the Attention tile while three projects are still being read does not say a smaller queue, it says nothing wants a human.

Above the scroller, and staying put as you scroll past the bands, is the page header: the project count, then tiles reading Running, Lanes, Failing, Attention and a progress bar. A tile showing an em dash is not loading and is not zero: it is a figure this build cannot derive from what is on the wire, said out loud rather than promised with a skeleton that would never settle. The two are different claims and the page draws them differently on purpose.

Notifications

The bell sits in the rail beside the dashboard button, carries the unread count, and opens a fleet-wide panel of what has changed: rows grouped by project and then by day, newest first, each with a class dot, the line the event renders as, and the transition that produced it. A row is one click from the entity it names. Opening the panel marks nothing read. Mark all read is what does, and it is the only control that moves the cursor.

A fresh install shows an empty panel that says why it is empty rather than one that looks broken. That is correct: events are derived by comparing a project against its previous snapshot, so the first read of a project reports nothing at all. The bell is outside the shell's has-any-projects guard, so it survives the no-projects state too.

Toasts are the third notification surface and the only one that appears without being asked for. They stack inside the workspace, pause while the pointer or the keyboard is on them, and are fleet-wide. A toast fires for whichever project the event names, which is routinely not the one on screen.

How events are derived, what each class does under a burst, and how to mute a project

Inside one project

Clicking a rail avatar opens that project's own screens. The nav lists them in three groups, and two of the rows are conditional: a project with a single checkout has no Worktrees row and a project that has never executed anything has no Runs row. Hiding the row is not the whole rule. A hand-typed ?view=worktrees on such a project resolves to Overview rather than rendering a screen with no nav row beside it.

The per-project screens, their nav group, and the view name in the URL
Nav rowGroupURLThe count beside it
Overviewunnamed?view=overviewnone
Boardunnamed?view=boardtasks
Timelineunnamed?view=timelinemilestones
DecisionsKnowledge?view=decisionsdecisions
RisksKnowledge?view=risksrisks
DocsKnowledge?view=docsdocs
AgentsExecution?view=agentsdiscovered agents
RunsExecution?view=runsruns. The row is absent until the project has one
WorktreesExecution?view=worktreeslanes. The row is absent on a single checkout
ActivityExecution?view=activitynone

A project whose files have problems carries a banner above whichever screen is showing: errors and warnings grouped by cause, foldable, and never fatal. A file that fails its schema is listed with the reason rather than dropped, which is the same promise blink validate makes at the command line.

Overview

The project at a glance. A header with the project's name and status, a line reading Milestone 3 of 7 · 41 / 79 tasks done · 2 blocked, the progress bar under it, then four tiles: In progress, Blocked, Done and Open risks.

  • Milestones: one row per milestone with its own bar and a done/total figure. The denominator is total minus dropped everywhere on this page, so dropping work moves the percentage by leaving the denominator rather than by counting as done.
  • Blocked & deferred is the one panel that mixes states: blocked tasks, then open risks with their severity, then dropped tasks dimmed. Dropped work is never a board column, and this is where it goes instead.
  • In flight now: every task at in_progress with its owning agent, or the word unowned.
  • Recent activity, marked from git, is the eight newest file changes. The full feed is the Activity screen.

A project with parallel work gains a band above the panels reading how many lanes are active out of how many exist, how many tasks are live off the main checkout, the oldest idle lane and its branch, and how many lanes have code differing from the main checkout while their tracker does not. That last figure is drawn in red, and it goes absent rather than to zero when there is none. It counts lanes that are coding without recording it, and it is the item on this page worth interrupting someone about. On a single checkout the band is not drawn at all: there is no row saying 0 lanes.

Board

Six columns, always all six, in this order: backlog, planned, in progress, blocked, paused, done. A status you never use is a fact rather than an absence, so its column stays and reads zero.

Tip

Six columns, seven statuses. dropped has no column and never will: dropped work is not deleted, but it is also not queued, so it appears in the Overview's Blocked & deferred panel and in the detail pane rather than on the board. A reader counting columns against the task enum and finding one missing has found this, not a bug.

  • A card carries its id, title, milestone chip, owner chip and tags, and the 3px left edge repeats its status. A card that names blockers lists them inline by id, and it does so on planned and in-progress tasks too, which is a fact no column states.
  • Files that failed validation cannot sit in a column, because they have no status to sit under. They get an Invalid strip above the columns instead, with the id each file claimed and the reason. Nothing is dropped on the floor.
  • Archived tasks are hidden, and the Done column's header carries a +12 archived toggle when there are any. An empty-looking Done column on a long-running project is usually this.
  • The toolbar filters by milestone, agent and tag, plus a worktree dimension on a project with lanes. Values within one picker are OR and pickers are AND: two tags widen, adding an agent narrows. The tally reads 18 of 35 tasks so a filtered board never looks like a shrunken project.
  • The sort control offers Board order (the order field, then oldest), Newest first, Oldest first, Recently updated and Stalest first.

Where work runs in git worktrees, Blink reads every lane's tracker and folds them into this one board, so a lane is visible while it works rather than after it merges. Two marks come out of that fold and they mean opposite things:

The two lane marks a task card can take
MarkMeansThe card's status is
⑂ feat/authWhat you are looking at came from that lane, and it says something the main checkout does not. That covers a task which exists only in that lanethat lane's
The lanes disagree about this task, and the main checkout's copy won the foldthe main checkout's

So a chip with no mark is the new-in-lane case: only one lane has the task, so nothing disagrees. A mark with no chip is the opposite: the status on the card is the main checkout's, so there is no other lane to name, and the mark is what says somebody else has a different account of it. Open the card to see every lane's version side by side.

The worktree picker adds two synthetic entries beside the branches, any lane and diverged only, and it rides the wt parameter rather than a longer one so a filtered board survives being pasted into a chat.

Timeline

Milestones as bars from start to target, with a today marker down the chart and six labels at most across the axis. A milestone that starts and ends on one day draws as a diamond rather than as a sliver, because a sliver reads as a very short duration and that is a different claim.

  • A slipped milestone keeps a dashed ghost bar at its original target and a +2w or -5d beside its title. The slip stays visible rather than being rewritten away, which is the whole point of recording the original.
  • A milestone with no usable date is listed with a note where its bar would be, not hidden. Hiding it would make an unset date invisible in the one view whose subject is dates.
  • Dropped milestones are drawn hollow and dashed rather than removed.
  • More than one lane splits the chart into swimlanes, one per lane, headed by the branch. The bars are the same bars in the same order. A swimlane groups the chart and never changes it.

Decisions, Risks and Docs

Three separate screens under the Knowledge group, not one. Each is the board rotated: every status in that type's own enum gets a group, always, even when empty. That is why none of the three offers a status filter: a filter that hid those groups would delete the rule from the screen while leaving it in the code.

  • Decisions filter by tag and are ordered newest first.
  • Risks filter by severity and tag and are ordered by severity.
  • Docs filter by type and tag.

All three search over the same box as the board. Ids are unique per type rather than globally, so x-1 can be a decision here and a doc one screen over; the URL carries the kind with the id for exactly that reason, and a link to one can never land on the other.

Agents

One card per discovered agent: its avatar, name, a badge saying where the definition was found, its description, model and tools, the file it came from, and a done / doing / blocked / planned split over the tasks that name it as owner. Under the split is what it is working on now, in-progress tasks first and then blocked. A strip under the toolbar counts the roster by harness and prints the paths that were scanned; it is roster-wide on purpose, because a filter narrows the grid and not what is true of the project.

  • An owner matching no discovered agent still gets a card, drawn on a dashed border with a ? avatar and a sentence saying how many tasks carry it. A human name and a typo both surface here instead of vanishing, and the match is exact. Folding case would hide the typo the card exists to reveal.
  • A skill's card reads skill · informational where the split would be. Skills are never task owners, so an empty skill card is the expected state rather than idle capacity.
  • A defined agent with nothing assigned reads defined, no tasks assigned yet, so capability you are not using is visible.

Which paths are scanned, and what each source yields

Runs

One card per run, newest first: its status, id and title, the tier it was executed at, when it happened, and a summary reading 6 tasks · 3 waves · 2 merged with wave 2/3 beside it. A blocked run prints its reason on the card. Opening one gives the task set, the lanes with what each got to, and the gate commands in run order.

The gate list is a list of commands and never of results. A gate runs per merge, so passed is a fact about one merge at one moment and it lives in the run's own event log where it can be dated. Nothing on this screen will tell you a gate is green. A lane naming a task the snapshot does not hold keeps its row rather than being dropped, because a dispatch that really happened is worth more on screen than a tidy list.

Waves, tiers, lanes and what a run record holds

Worktrees

One block per git worktree of the project, with a summary reading 4 lanes · 2 active. Each row is named by its branch, or by its path where no branch names it. The row carries its status, commits ahead and behind as ▲3 ▼0, uncommitted tracker files, when it last wrote anything, and the run that dispatched it if any run claims it.

The status words a worktree row can carry
StatusMeans
primaryThe checkout the project's path points at
activeIn flight: not merged, not gone
syncedOrdinarily merged into the primary branch
absorbedSquash-merged, so the branch's own history will never look merged
goneThe directory is off disk and only the registration is left
  • An active lane three days cold takes an ⚠ idle pill, and one five or more commits behind takes a second naming the primary branch. Both are restricted to active lanes: a merged lane six days cold is a merged lane, not a stalled one.
  • Merged and vanished lanes stay listed and go quiet, carrying a prune hint: merged — safe to prune, squash-merged — safe to prune, or gone — run `git worktree prune`. They are sentences, not buttons.
  • Under each row are the entities that lane disagrees with the main checkout about, each with what the main checkout says about it, or new in lane where it holds no copy at all. At most six are listed and the rest roll up into a +N more line. A merged lane therefore lists nothing while keeping its row: it is listed because it differs, not because it holds entities.
  • A lane that could not be scanned is drawn dashed and says so, and its measurements read as an em dash rather than as clean or ▲0 ▼0. Nothing was read, which is a different claim from nothing being there.
  • A wt in the URL highlights the named rows here and hides none. On the board it filters; on this screen it anchors, because a lane picture with rows missing is not a lane picture.
  • Where worktrees.max withheld lanes, a line at the foot reads 3 lanes not shown (max 1). A cap of one leaves no rows at all, and that line is then the only true thing on the screen. It is also why the nav row survives a cap that empties the view.

Activity

The git feed at full page, grouped by commit and then by day, newest first. Each commit card carries its subject, author, short sha and age, then one row per changed file. A file row names the verb git gave it, the entity id, and a click through to that entity. The verbs are added, edited, deleted, renamed. Rows from a lane carry that lane's branch on the commit header; the main checkout's rows stay bare. A search box filters over the commits.

Tip

This is not your project's commit history. It is the last few hundred commits that touched the content directory, so a week of heavy coding with no tracker writes reads as an empty feed and is not a fault. Two other empty states say which is which: no git history for this content directory on a project that is not a repository, and a line naming git.activity when the feed has been switched off. Everything else on the dashboard works in all three cases.

No row here states an entity's status. A commit records a file operation, and drawing today's status at the end of a row describing a change from three weeks ago would read as though that commit set it.

The detail pane

Clicking anything that names an entity slides a panel in from the right: its frontmatter as metadata, its body rendered as markdown, and links in both directions: what this entity points at, and what points back at it. Every reverse index is computed, so a milestone lists its tasks even though no milestone file names one.

  • Panels stack rather than replace. Following a link from an open panel puts a new one on top, staggered, so the trail that led you there stays visible and clickable. Escape pops one, the browser's Back button pops one, and clicking the scrim closes the lot.
  • A dangling reference stays listed and disabled rather than being hidden. The validator already warned about it, and hiding the row here would make that warning unexplainable.
  • Where lanes disagree about an entity, a per-lane table shows each lane's account of it, with the row that won the fold marked. Each of those rows gives that lane's status for the entity and when it last changed. That is where a card's branch chip or warning mark gets explained.

What lives in the URL, and what does not

There is no router. Every navigable thing is a query parameter on /, which means any view you are looking at can be copied out of the address bar and sent to somebody. This is the most useful property of the whole dashboard and it is invisible unless said.

http://127.0.0.1:7777/?project=blink&view=board&tag=parser&agent=reviewer&open=task:t-0bst0k

A board narrowed to one tag and one owner with a task panel open. That is the whole of this app's routing: no path segments, no fragments, one query string.

Every parameter the dashboard reads
ParameterCarries
projectThe registry id. Absent means the cross-project page, which is the view above projects
viewOne of the view names in the table above. Absent means the cross-project page
tabThe density, on the cross-project page only
milestone, agent, tag, severity, typeFilter selections, repeated once per value rather than delimited, so a tag containing any given character is still safe
wtLane keys: a branch name, or a path where a branch names two lanes or none
qThe search box, verbatim, spaces included
openThe panel stack, root first, one <kind>:<id> per open panel. Order is the meaning, so this one is never sorted

Defaults are omitted, so a plain cross-project link is the bare URL and an unfiltered board is just ?view=board. Changing project, view, or opening a panel pushes a history entry; typing in a filter or a search box replaces one, so Back stays useful instead of undoing one keystroke at a time.

What is not in the URL is worth knowing, because it is what a link will not carry to the person you send it to: which projects you have hidden, whether archived tasks are showing, the board's sort, which Attention sections you folded, and whether the sidebar is collapsed. Those are how one reader reads rather than where a link points, and they live in that browser's local storage.

Losing the connection

The dot in the sidebar footer is the connection state: watching with the time of the last sync, connecting…, or offline · retrying. A dropped socket keeps the last state on screen rather than blanking the page, and reconnects on a backoff that doubles per consecutive failure; a socket that opens and dies immediately does not clear that backoff. On reconnect the client catches up on what it missed, so a gap closes rather than leaving a hole.

Stopping the server does not lose anything, because the server holds nothing. Every screen above is a reading of the markdown in your repository plus what git says about it, so the answer to a dashboard that looks wrong is usually blink validate rather than a restart.

Notifications

Blink derives notifications rather than recording them. An event is a function of two consecutive snapshots of one project. A task moving planned to in_progress is task.started. Nothing extra is written for the inbox to fill, so nothing in the inbox can drift out of step with the tracker it came from.

Each comparison runs against a stored baseline, the last snapshot the server settled for that project. The baseline is on disk, so a restart reports what changed while the server was down instead of losing it.

Note

A project with no stored baseline has nothing to compare against, and its first diff emits nothing at all. An empty inbox on the day you register a project is correct rather than broken. The first thing you are told about is the next transition after that.

What produces an event

Three channels transition and nothing else does: the status of a task, a milestone or a run, the state of a lane inside a run record, and the number of validation errors in the project.

Decisions, risks and docs carry a status too and never produce an event, because no kind maps to one. The baseline holds tasks, milestones and runs only, so a body edit, a new field or a renamed file passes without a word.

Every event kind, its class, and the transition it is derived from.
KindClassDerived from
milestone.delivereddeliverableA milestone's status reaching done
run.finisheddeliverableA run's status reaching done
task.blockedattentionA task's status reaching blocked
run.blockedattentionA run's status reaching blocked
gate.failedattentionA lane's state reaching failed. The event names the lane's task rather than the run, because which task broke is the question a failed gate raises
validate.errorsattentionThe project's error count going from zero to more than zero
milestone.startedprogressA milestone's status reaching active
task.startedprogressA task's status reaching in_progress
task.doneprogressA task's status reaching done
wave.completedprogressEvery lane of one wave settling at merged or skipped, with at least one merged
progress.rollupprogressSynthesised by the burst cap below. The differ never produces this one

Four of those kinds come out of a run record rather than out of a task or a milestone. A failed lane produces a gate.failed, and it also keeps its wave from ever emitting wave.completed, because announcing a wave as complete would contradict the failure in the place the reader is looking. What a run, a wave, a lane and a gate are is the execution model:

Running work: tiers, waves, lanes and gates

The five derivation rules

  • Cold start emits nothing. A project with no stored baseline produces no events on its first diff, whatever is in it.
  • The folded status is diffed, never a lane's copy of it. A task living in four worktrees is one entity by the time the differ sees it, so a checkout in one lane cannot produce a status storm.
  • Repeated transitions collapse. A burst carrying a task from backlog through in_progress to done is one task.done. A round trip such as done to blocked to done is dropped, because it transitioned twice and changed nothing.
  • Appearing and disappearing are not events. An entity the baseline has never seen produces nothing on the diff that first sees it, and one that vanishes produces nothing either. A branch switch is a checkout artifact rather than news.
  • Only the three channels above transition. Every other difference between two snapshots is invisible to the differ.

Several real transitions have no kind at all, and they are recorded in the baseline in silence. A task moving to planned, paused or dropped notifies nobody. validate.errors fires only on the move from zero errors to some errors: going green again has no kind, and a count moving from one non-zero number to another is the same failure being recounted while you fix it.

Classes, and what a class decides

Every event carries a kind and a class. The kind is what happened. The class is how loud it is, and the class is what the burst cap reads.

deliverable and attention are never rolled up. progress is the only class that collapses, so the interrupts that matter do not get buried under the ones that do not.

A kind's class is fixed in the binary. Nothing in the configuration moves a kind between classes, subscribes to one class, routes a class somewhere, or turns a single kind off. The three fields below are the whole surface.

Where notification state lives

All of it is user level, under ~/.blink/notify/, and none of it is in your repository or in git.

tree
~/.blink/notify/               # 0700
  config.json                  # 0600, your machine-local preferences, hand-edited
  events.jsonl                 # the event log, capped at 10,000 events
  events.1.jsonl               # one rotation of the same, kept and then replaced
  inbox.json                   # your read cursor and your per-project mutes
  baselines/<projectId>.json   # the snapshot each project's next diff runs against

The directory is 0700 and every file in it is 0600.

Keeping it out of the repository is deliberate. An event is derived, and git already holds both snapshots it was derived from, so a committed event file would be a second and staler copy of something the tracker already says. A read cursor is personal to one person on one machine, and an append-only log crossed with worktree lanes conflicts on every merge.

The consequence is the part people file as an oversight: your notification state does not travel between machines, and a teammate never sees your inbox.

events.jsonl is one file for the whole fleet with one sequence counter across every project, which is what makes unread a single integer rather than a map. It is capped at ten thousand events and keeps exactly one rotation beside it. Reads answer from the live file only, so a client a full generation behind gets what is still retained rather than everything it missed.

A project's baseline file is named after the id in its blink.json, and the same id keys its mute. An id that does not match ^[A-Za-z0-9][A-Za-z0-9._-]*$ can key neither, so that project gets no notifications at all and the server log says so once, by name.

Every path under ~/.blink, in one table

Note

There is no blink notify command, and the absence is deliberate. Notifications are configured by editing one of the two files below, and the only control at runtime is the mute toggle in the dashboard's notifications panel. A reader who goes looking for a command concludes the feature is missing.

The notify block

Three settings, resolved per project. Every field is optional in both of the files that can set them.

json
"notify": {
  "settleMs": 1000,
  "burstCap": 25,
  "muted": false
}

The block in full, with every field written out at its default. Write only the fields you mean: see Precedence below for what an absent one does.

The three notify settings, their defaults and their bounds.
FieldDefaultAcceptsWhat it does
settleMs1000A whole number from 0 to 60000How long new events are held before they are collapsed and emitted, in milliseconds. 0 disables the window
burstCap25A positive whole numberHow many events one settled batch may itemise before the progress class is rolled up into a single line
mutedfalsetrue or falseSilences this project

The settle window is fixed rather than rolling. It opens on the first snapshot and is never restarted by a later one, so a project being written to continuously still reports and emission latency stays bounded by settleMs. A snapshot arriving inside an open window replaces the one being held rather than being diffed against it.

The window also holds the baseline from the moment it opened, and diffs that baseline once against the final snapshot. That is what stops a task going planned to in_progress to paused inside one window from announcing a task.started for a task that is now paused. settleMs: 0 turns the window off and emits once per snapshot.

The bound closes a trap rather than expressing an opinion. setTimeout clamps any delay above 2³¹−1 milliseconds to 1 millisecond, so a very large settleMs would produce the loudest possible behaviour instead of the quietest, with nothing to say so. Wanting to hear about a project less often than once a minute is a different intent, and that is what muted is for.

The burst cap triggers on a settled batch strictly larger than the cap. The cap is what is allowed rather than what is too much, so a batch of exactly burstCap events stays itemised.

Only the progress events collapse, into one progress.rollup line that takes the position of the first event it replaces. The rollup names no entity and carries no transition, because it stands for many entities at once. A batch holding fewer than two progress events is left alone: replacing one line that says what happened with a line saying 1 progress update helps nobody.

Every settled batch is capped, not only the first one after a restart. A git pull landing two hundred transitions is the same firehose as a backfill and gets the same treatment.

Precedence

Three sources sit over the built-in defaults, and the committed one is the weakest of the three.

The notify precedence chain, weakest first.
LayerSourceIs
0Built-in defaultssettleMs 1000, burstCap 25, muted false
1notify in the project's blink.jsonCommitted, shared with the team
2defaults in ~/.blink/notify/config.jsonYour preference, everywhere
3projects.<projectId> in the same fileYour preference, for one project

That inversion is the decision. A committed team preference must never be able to make a personal channel louder than its owner asked for, and letting the machine-local file win every field it mentions is the only way to guarantee it.

Layering is per field rather than per block, so absence is data. A field nobody wrote means no preference was expressed, and it falls through to the layer below. This is why notify is the one block in blink.json whose absent fields are not filled in at load: a resolved block supplies a value for every field, and a global preference would then be overridden by every project that has a notify block at all.

json
{
  "name": "Blink",
  "id": "blink",
  "notify": { "settleMs": 5000, "burstCap": 10 }
}

blink.json, committed with the project.

jsonc
{
  "defaults": { "settleMs": 2000 },
  "projects": {
    "blink": { "burstCap": 50 },
    "noisy-repo": { "muted": true }
  }
}

~/.blink/notify/config.json, machine-local and never committed. Both levels are optional.

Resolving the settings for the project whose id is blink.
Fieldblink.jsonconfig.json defaultsconfig.json projects.blinkResolved
settleMs50002000Not set2000
burstCap10Not set5050
mutedNot setNot setNot setfalse

The project's own settleMs of 5000 loses to a preference written once on this machine. burstCap is taken from the narrowest layer that mentions it. Nobody mentions muted, so it falls all the way through to the built-in default. The second project in that file, noisy-repo, has no notify block of its own and resolves to a settleMs of 2000, a burstCap of 25 and muted: true.

An empty config.json, or no file at all, is legal and silent. A file that cannot be read, is not JSON, or carries an out-of-range value falls back to the built-in defaults and reports one line in the server log. The server keeps running, because a typo in an optional preferences file is not a reason to stop serving, and the same line is not repeated on every reload.

The two sources fail differently, and it is worth knowing which one you are editing. A bad value in config.json falls back as above. The same bad value in blink.json stops the project loading. Unknown keys are stripped rather than rejected in both, so a misspelt settleMS loads silently as the default. Values are checked, key names are not.

The rest of blink.json, key by key

Mutes

Muted is the OR of two independent sources and both are honoured. One is muted from the precedence chain above. The other is the per-project mutes in ~/.blink/notify/inbox.json, which the notifications panel toggles and which touch no config file.

The panel writes inbox.json only. A project silenced by muted: true in a config file therefore cannot be unmuted from the dashboard, because that mute lives in a file and only an edit to the file clears it. A project that reports nothing is worth checking the whole chain for rather than only the panel.

A mute suppresses delivery. The project is still scanned, still diffed, and its baseline still advances at the end of every settled batch. Unmuting therefore reports what changes from that moment on, rather than dumping everything that happened while the project was quiet. A mute means do not tell me about this, not save it up for later.

In the notifications panel, a muted project's rows are withheld behind a reveal in the footer rather than greyed in place, and they never raise the unread badge. The footer says how many are being held and puts them back in one click.

curl -X POST 127.0.0.1:7777/api/notifications/mutes -H "content-type: application/json" -d '{"projectId":"blink","muted":true}'

The same route the panel's toggle posts to. It answers with the whole mute set rather than with the id that changed.

Setting a mute checks that the project id can key the notify tree. Clearing one deliberately does not, so { "muted": false } is always idempotent and always a 200. The asymmetry is what keeps a key hand-edited into inbox.json clearable from the panel, because validating both arms would leave a mute that can be listed and never removed. Setting a mute to the value it already holds writes nothing.

Over HTTP

Three routes, on the same local server as the dashboard, all listed by GET /.

The three notification routes.
RouteDoes
GET /api/notifications?since=&project=Everything after since, plus the read cursor and the whole muted set
POST /api/notifications/cursor{ seq }, marks read up to that event and answers with the stored cursor
POST /api/notifications/mutes{ projectId, muted }, answers with the whole mute set
  • since past the newest event is an empty list and a 200, never an error. A client asking what it missed after a rotation is not making a bad request.
  • An empty value is an absent one for both query parameters, so ?since=&project= is the whole fleet from the beginning of what is retained.
  • seq is clamped to the newest event the log has issued, so a cursor cannot be pushed past the log and leave the unread count dead. The route answers with what was stored rather than with what was asked for.
  • Only the cursor route moves the cursor. Nothing on the server advances it for you, and opening the panel marks nothing read, so an inbox that never clears is usually a client that never posted.
  • Both POSTs are refused with 403 forbidden_origin from an origin that is not the dashboard's own. A request carrying no Origin header at all is a non-browser client such as curl, and it stays allowed.

All three answer 503 notifications_unavailable under exactly two conditions. Either this server has no event store, because it lost the lockfile race and another process owns the log, or inbox.json could not be written. Both mean the feature is temporarily absent. Neither is a bad request and neither is a broken server, and the rest of the API keeps serving in both cases.

Delivered events reach an open dashboard over the same WebSocket the rest of the interface uses. A page that has just loaded, or has just reconnected, asks GET /api/notifications for what it missed.

Configuring a project

Everything configurable is either a key in blink.json or an environment variable. There is no settings screen, on purpose: a project's setup belongs to the project, so it travels with the repository and a clone behaves the way the project asked rather than the way one machine was once configured.

The whole file is shown as a single tree, and every key below is listed once with its default, in the format. This section is the jobs, one at a time.

blink.json is watched alongside the content directory, so a running dashboard picks up an edit within a moment. Nothing here needs a restart. The one setting that does is the dashboard password, which is not in this file.

Move the content directory

contentDir is where the entity files live, relative to the project root. Any folder inside the root works.

blink.json
{ "contentDir": "docs/plan" }

Changing the key does not move any files. Move them yourself, with git mv so the history follows, and change the key in the same commit. The two ways of getting it wrong fail differently, and the difference tells you which mistake you made.

  • A directory that does not exist is a validation error, exit 1. The project is a Blink project, it loaded, and its content is missing.
  • A path that resolves outside the project root, through .. or through a symlink, is a config error, exit 2. Nothing loads at all, because the containment boundary the file server relies on is the one thing that cannot be relaxed by config.

After the move, re-run blink init so the agent instructions point at the new path. They name the content directory in prose, and a skill that still says .blink will send an agent to write there.

Set a colour or an icon

color sets the project's tile in the rail and its accent on the dashboard. Absent, Blink derives a stable one from the project id, so a project always has the same colour without anyone choosing it.

blink.json
{ "color": "#0a84ff", "iconText": "MP" }

The value is six-digit hex and nothing else. rebeccapurple, rgb(90, 200, 250) and the three-digit #0af are all rejected while the file loads, which is exit 2 rather than a warning about one field. The project stops loading entirely and drops off a running dashboard until the value is fixed, which is worth knowing before you edit this key while the server is up.

For the tile itself, iconPath points at an image inside the project and wins over iconText, which is one or two letters or digits and wins over the initials derived from the name.

Turn the git feed off

blink.json
{ "git": { "activity": false } }

This stops the Activity view reading the git log for this project. Everything else keeps working, including worktree lanes, which are discovered from the repository rather than from the log. Reach for it on a repository whose history is enormous or whose commit messages you would rather not have on a screen.

Bound worktree scanning

When work runs in git worktrees, Blink reads each lane's tracker and folds them into one board. Three keys bound that.

blink.json
{
  "worktrees": {
    "track": true,
    "ignore": ["*-scratch", "tmp/**"],
    "max": 12
  }
}
  • track: false stops lane discovery for this project entirely. The Worktrees view goes with it.
  • ignore is a list of globs, matched against a lane's directory name and against its path from the repository root. * stops at a slash and ** crosses one.
  • max caps how many lanes are scanned, not how many are listed. When it bites, the most recently written lanes win and the view says how many were left out, so a cap is never silent.

How lanes and worktrees are used during a run

Pin a port

The server binds 127.0.0.1:7777. When that port is taken it walks upward until it finds a free one, so the URL you memorised may not be the one serving today. Pin it when something else depends on the address.

blink start --port 7800

Also accepted by serve and restart. A pinned port still walks upward if it is taken.

blink status

Prints the port actually in use, with the pid, the url and the uptime.

The port is a property of the server rather than of a project, so it lives on the command and not in blink.json. One server serves every registered project.

Stop it opening a browser

Set BLINK_NO_OPEN in the environment and no command launches a browser. Any non-empty value counts, so BLINK_NO_OPEN=0 and BLINK_NO_OPEN=false both suppress it just as firmly as 1. Unset it, or set it empty, to get the browser back.

BLINK_NO_OPEN=1 blink open

Starts the server if none is running and prints the url instead of opening it. Useful over ssh and in a container.

Declutter a busy board

Three fields on the entities themselves, rather than keys in blink.json, do the work here.

  • archived: true on a task hides its card from active views while keeping the file, its history and its place in the progress numbers. It is legal only on the two task statuses the field table names, and setting it anywhere else is a validation error rather than a quiet no-op.
  • order pins a card's position within its column. A task without one sorts by id, after every task that has one.
  • tags become filters on every list and board, so a tag is the cheapest way to carve a large project into views without splitting it.

Every field these three sit alongside

Point agent discovery somewhere else

agents.scan is the list of paths scanned for agent definitions. Add your harness's location to it, or set it to [] to show only the owners named on tasks.

blink.json
{ "agents": { "scan": [".claude/agents", ".claude/skills", "AGENTS.md"] } }

Writing the key replaces the default list rather than adding to it, so include the entries you still want.

What each scanned source yields

Set notification defaults for the team

The notify block in blink.json is a team default and the weakest of the three sources that can set it. Your own machine-local file beats it field by field, which is the opposite of what project-beats-global config usually means and is deliberate: a committed preference must never be able to make somebody else's channel louder than they asked for.

blink.json
{ "notify": { "settleMs": 2000, "burstCap": 50 } }

Write only the fields you mean. A field left out falls through to the layer below rather than to the default.

The precedence chain, its bounds and its mutes

The project id, and when it stops being editable

id defaults to the slugified name and is the key the project is addressed under: in the registry, in the dashboard URL and in the filename of its notification baseline. It is read once, when blink add registers the project, and never re-read.

So changing id in blink.json afterwards appears to do nothing. The registry keeps the id it recorded, and so does everything keyed by it. To actually change it, unregister and register again.

blink rm . && blink add .

Reads the new id. Nothing under the content directory is touched by either command.

If two registered projects would take the same id, the second one gets a numeric suffix, so a second checkout of the same repository registers as myproject-2 rather than displacing the first.

Where the registry and the rest of Blink's own state live

Locking it down

Blink is designed to stay on the machine it runs on, and four properties hold it there without any configuration from you.

  • The server binds 127.0.0.1 only, never 0.0.0.0. The address is not configurable.
  • Any request whose Host header is not a loopback name is refused. That is a DNS-rebinding defence: a hostile page can point its own hostname at your loopback address, but it cannot change the Host the browser sends.
  • WebSocket upgrades from any other origin are refused, so the live feed is covered by the same rule as the rest.
  • File reads are constrained to each project's own content directory, symlinks included.

Those stop anything off your machine. They do nothing about anything on it. By default, any account with a shell on the same box can read every task, every decision and every registered path.

curl 127.0.0.1:7777/api/projects

What another account on the same machine can do while no password is set.

On your own laptop that is usually fine. On a shared box, a build agent or anything with more than one login, it is the exposure worth closing.

Requiring a username and password

blink set-password

Prompts for the password, then asks for it again. Nothing is written unless the two match.

It stores a salt and a scrypt-derived key in ~/.blink/auth.json at mode 0600. The password itself is never written anywhere. The username defaults to blink and --user changes it.

blink restart

Required. The credentials are read once at startup, so a running server does not have them yet.

That restart is the step people miss. Setting the password and immediately reloading the dashboard shows no prompt at all, which reads as a command that did nothing. It is not: the file is written, and the server that is running started before the file existed. The command prints the restart line for exactly this reason.

After the restart every route asks for it, not just the API: the UI, the fonts and the live WebSocket included. The gate is installed before the routes it guards, so there is no window in which the HTML is served openly and only the data behind it is protected.

It prompts rather than taking the password as an argument, and that is a security property rather than a courtesy. A password on the command line lands in your shell history and in ps output, readable by exactly the accounts this is meant to keep out.

blink clear-password

Removes it again, confirming first. --yes skips the confirmation. Also needs a restart.

It confirms because removing authentication is a downgrade and should not be one typo away from a command that looks almost the same. Clearing when nothing is set succeeds and says so.

Authentication is off by default, and a machine that never runs set-password behaves exactly as it did before.

What the password does not buy you

The Host guard above will refuse most such setups anyway, and that refusal is the design working rather than a bug to route around. Exposing a read of every repository you track is not a thing a password makes safe.

A password also does not unlock editing, because there is nothing to unlock. Nothing in the browser writes a file at any privilege level; the dashboard is read-only by construction rather than by permission. See the dashboard.

If you lose it

There is no recovery path through the CLI, by design: a command that could reset the password without knowing it would be the way past the password. Delete ~/.blink/auth.json and restart, and the server is open again. blink clear-password does the same thing from a shell that has the file.

If that file is present but unreadable or hand-edited into something invalid, the server fails closed and refuses every request until it is fixed or removed. A broken credentials file must never be the reason authentication quietly switches itself off.

Where auth.json sits, with the rest of Blink's own state

Your project's state is in your repository. Blink's own state is in ~/.blink/, it is per user and per machine, and none of it is in git. This is the map of it.

~/.blink/
~/.blink/
  registry.json                  the projects you have registered
  server.lock                    the running server: pid, port, start time
  server.log                     stdout and stderr of a backgrounded server
  auth.json                      the dashboard password hash, if you set one
  notify/                        0700
    config.json                  0600, your machine-local notification preferences
    events.jsonl                 the event log, capped at 10,000 events
    events.1.jsonl               one rotation of the same, kept and then replaced
    inbox.json                   your read cursor and your per-project mutes
    baselines/<projectId>.json   the snapshot each project's next diff runs against
What each path holds and what writes it
PathHoldsWritten by
registry.jsonOne entry per registered project: its id, its absolute path, a cached colour and the day it was added. A registered path whose blink.json has since vanished is shown greyed as missing rather than droppedblink add and blink rm
server.lockThe pid, the port and the start time of the one server allowed to run. A stale lock left by a crash is recovered automaticallyThe server, on start and on stop
server.logEverything a backgrounded server writes to stdout and stderr, so it goes somewhere readable rather than nowhereblink start and the server it launched
auth.jsonA scrypt hash of the dashboard password and its username. Absent unless you set oneblink set-password and blink clear-password
notify/config.jsonYour notification preferences, global and per project. Hand-edited, and created empty on demand, so its absence is normalYou
notify/events.jsonlThe derived event log across every project, one global sequence, capped and rotated once into the file beside itThe server
notify/inbox.jsonYour read cursor and the per-project mutes the notifications panel togglesThe server, on your instruction
notify/baselines/One snapshot digest per project, which the next diff is taken against. This is what lets a restart backfill instead of starting blindThe server

The notify directory is owner-only and so are the files in it, and auth.json is written at 0600 on a temporary file before it is moved into place, so it is never briefly readable. registry.json, server.lock and server.log are not owner-only, so on a shared machine another account can read the list of every project path you have registered. That is one of the things a password does not fix and loopback binding does not fix either.

What the loopback bind and a password do and do not stop

None of this is in your repository, and the notification half is deliberately outside it rather than by omission. An event is derived from two snapshots git already stores, so a committed copy would be a staler second version of a fact the tracker already holds. A read cursor is personal to one person on one machine, and committing it would churn the repository every time you glanced at the inbox.

The full argument, and what the notify files mean

What deleting it does

Deleting ~/.blink/ unregisters every project, empties your inbox and removes the dashboard password. It touches none of your projects: every task, decision, run and blink.json is in your repositories and is untouched by anything under this directory. Registering a project again brings it back whole.

The notification baselines go with it, so each project's next diff is a cold start: it emits nothing rather than replaying what you missed. The password goes too, and that one is a feature. Nothing in the CLI can read a hash back, so deleting the file is the only way in after forgetting a password.

Do it with the server stopped. Deleting the directory under a running server takes the lockfile with it, and the process keeps serving with nothing left to record that it exists.

blink stop

Exits 0 when there was nothing to stop, so it is safe to run first either way.

Troubleshooting

Start from what you are seeing. Each row names a symptom, what to do about it, and the section that explains it. Nothing is explained twice here: this is an index into the sections above.

At the command line

Symptoms you meet while running Blink
What you seeWhat to doExplained in
blink init reports every file as kept and upgrades nothingRun /blink:setup in your agent. Installing the package and re-running the scaffold are the first two steps of an upgrade, and this is the third.Installing and upgrading
blink add refuses, or registers the wrong directoryType the dot. blink add . takes the path to register and has no default.The CLI
A project is listed (missing), or its avatar in the rail is greyed outPut the checkout back where it was, or unregister the row with blink rm. Nothing on disk is deleted.The CLI
Your CI step passes on a project you know has problemsRead the output rather than the exit code. Warnings exit 0 and only errors exit 1.Exit codes
blink validate exits 2 and says nothing about your filesFix blink.json first. Nothing was read, so nothing was reported.Exit codes
The dashboard is not at the address you rememberRun blink status for the port. The default moves up when something else has it.The CLI

After an edit to blink.json

Symptoms that follow a configuration change
What you seeWhat to doExplained in
The whole project drops off the dashboard after you set a colourPut back a six-digit hex like #0a84ff. Nothing else is accepted, and the project returns on its own.Configuring a project
You moved the content directory and now nothing loads at allKeep the new path inside the project. A path that reaches outside it stops the project loading at all.Configuring a project, with the codes in Exit codes
You changed id and nothing changed anywhere elseUnregister the project and register it again. The id was taken once, when you added it.Configuring a project
The notification settings you committed do nothing on your own machineChange your machine-local settings instead. They beat the committed block, field by field.Notifications
The dashboard still asks for no password after you set oneRestart the server. The one running started before the password existed.Locking it down

In the entity files

Symptoms in the markdown you write by hand
What you seeWhat to doExplained in
Tasks disappeared after you tidied them into foldersMove them back up one level; a folder inside a type's directory is not read. Use archived: true to put finished work out of the way instead.The format
A file fails validation for a status that is not even in its listMove the file back rather than adding a field. Its folder decides what kind of entity it is.The format
A milestone will not validate at in_progressUse active. Every type has its own statuses and they do not overlap.Entity types and statuses
A task fails validation and nothing about it looks wrongCheck the two conditional fields. A blocked task must say what blocks it, and a paused one must say why.Every field, type by type
The progress bar will not move, though work keeps getting parkedDrop what the project will not do. Pausing leaves work in the count; dropping takes it out and still keeps the file.The format, with the statuses in Entity types and statuses

Agents and runs

Symptoms around the agents doing the work
What you seeWhat to doExplained in
An owner on a card has a ? avatarCorrect the spelling, or leave it if the owner is a person. The match is exact.How agents are discovered
An agent never shows up as an owner, whatever you call itCheck whether it is a skill. Skills do the work and are never assigned it.How agents are discovered
Running the execute skill again carries on instead of starting freshLand or close the open run first. A second invocation resumes it rather than starting over.Running work
An agent on a machine without Blink writes files that do not validateRe-run blink init and commit what it writes. The instructions live in your repository, not in the install.How agents are discovered

On the dashboard

Symptoms on a screen that looks wrong
What you seeWhat to doExplained in
The notification panel is empty on a project you just addedNothing to do. The first change after you added it is the first thing reported.Notifications
The board has six columns and there are seven task statusesLook on the Overview. Dropped work is kept, but it is never queued.The dashboard
The Done column looks empty on a project that has shipped a lotUse the archived toggle on that column's header. The cards are folded away, not removed.The dashboard
A project has no Worktrees row, or no Runs row, in its navNothing is broken. Each row appears once there is something in it.The dashboard
The Activity feed is empty after a week of workNothing is broken. That feed follows the tracker rather than the code.The dashboard
A screen shows something you know is out of dateCheck the connection dot in the sidebar footer, then run blink validate. Every screen is a reading of your files.The dashboard