SPRINT C: Dev Control portal + independent verification
§7, the gates: {} judgment call. The one place I read estate.ts as outranking the literal brief. Three-line change if you disagree.
Access control. There is none, by design. Dev Control must be bound to the tailnet interface (or behind Tailscale Serve / an authenticating reverse proxy) — not 0.0.0.0. Anyone who can reach the port can read every run, every branch name, and the acknowledge action. Nothing sensitive is rendered, but nothing is gated either.
The acknowledge write. It creates BULGAR_ROOT/dev-control/ on first request via mkdir -p. Confirm you are happy with Dev Control creating a directory inside /srv/bulgar, and that dev-control/ is a name the supervisor will never claim.
Preview links are built from the request's Host header plus the recorded preview_port. That is correct when Dev Control is served by the same worker that publishes previews. Behind a proxy on a different host, the links will point at the proxy. Nothing is guessed — but confirm the deployment shape.
server-only. Install it on the worker and swap out server-guard.ts for the real build-time guarantee.
tsconfig.json was modified by next build (jsx: react-jsx, the next plugin, and .next/types in include). Standard Next 16 behaviour, but it is a change I did not author.
The lint gate change — next lint → eslint . with a new eslint.config.mjs. It was broken before; confirm the rule set is the one you want.
The 8 SUPERVISOR_LOST runs are a real signal, not a display bug. Dev Control now shows them clearly. Something killed those supervisors between 14:01 and 16:52 on 2026-08-25, and two of them had already pushed. That is worth a look independent of this app. ---
Starting SHA: 10c3edd56d8354a663461a5c8b2b74d361ba24b8 ("Add lockfile so npm ci works") Branch: agent/bulgar-dev-control/20260825T192754Z-0ed7 Developed against: BULGAR_ROOT=./fixtures — 23 real run meta.json files and the real 14-component estate manifest. The container cannot see /srv/bulgar, as expected.
A five-surface control room. Every route is force-dynamic; nothing is baked at build time, so one build runs in the container against ./fixtures and on the worker against /srv/bulgar.
| route | question it answers |
/ | what needs a decision, what the worker is doing, how fresh the record is |
/review | which finished work is waiting on a human |
/agents | what is running right now |
/estate | every component in the manifest, gate by gate |
/stats | engineering facts with real denominators |
/run/<id> | the full record of one run, including its raw meta.json |
src/lib/estate.ts — the scaffold's vocabulary, extended with the rest of the fields the real fixtures actually carry (agent, install, local_checkpoint, preview, cpus, memory, image, container, supervisor_pid, base_branch, preview_identity). Every accessor narrows to a closed union and falls back to UNKNOWN. Existing exports unchanged.src/lib/manifest.ts — the manifest's own rule encoded once: a null command means the gate does not exist and is NOT_PRESENT.src/lib/read.ts — the only filesystem access. Read paths only, except the one write below.src/lib/derive.ts — pure, clock-injected derivations. No I/O, so the honesty tests can drive it directly with real fixture data.src/lib/load.ts — one memoised read per request (React cache), so the nav badge and the list under it describe the same instant.src/lib/reviews.ts + src/app/actions.ts — the single mutating path.Nothing is rendered that does not come from one of these. /run/<id> prints the raw meta.json at the bottom so any value on the page can be checked against its source.
runs/<id>/meta.json| displayed | field |
| mission / task | task |
| repository, component, working dir | repository, component, working_directory |
| exit status chip | exit_reason |
| agent status chip | agent |
| install chip | install |
| four gate chips | gates.test / .typecheck / .lint / .build |
| local / remote checkpoint | local_checkpoint, remote_checkpoint |
| preview chip and link | preview, preview_port |
| branch, base SHA, latest SHA | agent_branch, base_sha, remote_sha |
| "branch moved" | base_sha !== remote_sha, both required |
| started / ended / elapsed / age | started, ended |
| cpus, memory, image, container, pid | cpus, memory, image, container, supervisor_pid |
| runtime set | runtime_set |
| OPEN BRANCH | https://github.com/{repository}/tree/{agent_branch} |
| VIEW DIFF | https://github.com/{repository}/compare/{base_sha}...{remote_sha} |
| OPEN PREVIEW | http://{request Host}:{preview_port}, only when preview === 'RUNNING' |
| VIEW REPORT | report.md in the run directory, if it exists (fs.stat) |
estate-manifest.json| displayed | field |
| the component list itself | components[] — one row per entry, never reconstructed from run history |
| project family grouping | project_family |
| whether each gate EXISTS | test_command / typecheck_command / lint_command / build_command — null ⇒ NOT_PRESENT |
| runtime, node, python, package manager | runtime_set, node_version, python_version, package_manager |
| preview port, start command | preview_port, start_command |
| credentials | credential_requirement |
| services required | services_required |
| per-component notes | notes |
| recorded findings | findings (verbatim) |
| deliberate exclusions | not_in_estate (verbatim) |
A component's gate status is answered in this order and no other:
| UNKNOWN | why |
| a gate on a run that ended before writing it | the record stops there. The supervisor writes NOT_RUN explicitly when it knows (run …134831Z-3740 has all four written as NOT_RUN), so an absent key is a different fact from a NOT_RUN key |
latest sha on runs with no remote_sha | never recorded |
branch moved when either SHA is missing | undecidable |
| worker total CPU / RAM | nothing on disk records what the worker HAS. Reservations per run are summed and shown; the denominator stays UNKNOWN and no bar is drawn |
| install + all declared gates on the 9 components never run | no run metadata exists for them |
elapsed when started is missing | duration(null) returns the word UNKNOWN, never 0s |
the whole app, when BULGAR_ROOT is unreadable | every page shows an UNREADABLE banner naming the path it tried, not zeros — verified by running a second instance against a nonexistent root |
agent absent is rendered `NOT_RECORDED`, deliberately distinct from UNKNOWN: those runs carried no coding agent, so there is no agent outcome either way. 17 of 23 runs are in that state and none of them is counted as a pass.
36 tests, all passing, in tests/honesty.test.ts and tests/actions.test.ts. They read the real fixtures off disk rather than restating shapes inline, so a test cannot pass against a shape the supervisor does not write. Each names the specific lie it prevents.
A failed agent never reads as success. Runs …172642Z-45c1 and …175015Z-7980 pass test, typecheck, lint AND build, and pushed a branch. Both are agent: AGENT_FAILED. isCleanFinish is false for both, neither enters the clean-review queue, and the top flag reads:
*Agent FAILED. All four gates PASS — the checkout still builds, but the agent did not finish its work.*
Rendered output confirms it: /run/20260825T175015Z-7980 leads with AGENT_FAILED, and — because base_sha === remote_sha — also says "branch moved: NO — identical to base". That run pushed nothing new. All 4 AGENT_FAILED runs are excluded from awaitsReview.
`gates: {}` is never blank. Six real runs have exactly that shape. Every one of the four gates renders one of the six words. See §7 for the one judgment call here.
`SUPERVISOR_LOST` is visually and structurally distinct from `COMPLETED`. Different colour token, filled background, border-style: double, and a ⛓ glyph in the markup itself so the distinction survives a monochrome screen or a copy-paste. All 8 lost runs are cleanFinish: false and none enters the review queue. Two of them (0e2e, 13f6) reached remote_checkpoint: PUSHED — pushed work plus a dead supervisor is still not a completed run, and they appear under *"Pushed, but NOT a clean finish"*.
`INFRASTRUCTURE_FAILURE` is not conflated with product failure. Run …134639Z-5d01 has install: FAIL and all four gates FAIL under exit_reason: INFRASTRUCTURE_FAILURE. Presenting that as five product failures is exactly the "hunting a bug that does not exist" case. Dev Control shows the FAIL words verbatim on the run, but suppresses the GATE_FAIL / INSTALL_FAIL attention flags and replaces them with:
*INFRASTRUCTURE_FAILURE — the harness broke. The gate results from this run (install FAIL, test FAIL, typecheck FAIL, lint FAIL, build FAIL) are recorded but are NOT evidence of a product bug.*
The Home "gate failures" tile excludes them and says so in its basis line. Conversely run …134248Z-549f (lint FAIL, STOPPED_BY_OPERATOR) is flagged GATE_FAIL — the distinction is tested in both directions. In the estate, any gate sourced from an infrastructure-failed run is marked fromInfrastructureFailure and labelled *"harness evidence, not product evidence"*.
A live run whose agent has already died is not hidden. exit_reason: RUNNING with agent: AGENT_FAILED is a real state — the agent died, the container kept going. An earlier version of attention() filtered active runs out of the Home list entirely, which would have buried exactly the case that most needs a human. Active runs are now included whenever they carry a flag; the flags that only make sense for stopped runs guard on that themselves, so the two healthy live fixtures still carry none.
Other invariants tested: absence never becomes zero; a component with no runs shows no PASS anywhere; NOT_PRESENT is never inferred, only quoted; every published fraction has denominator 14 and a stated basis; exit-reason buckets are disjoint and sum to 23; capacity refuses to understate (one unrecorded reservation makes the whole sum null, not a smaller number); an unreadable meta.json is reported rather than skipped; and run ids that are not supervisor run ids are rejected before a path is built.
Four of my initial test expectations were wrong — I had miscounted SUPERVISOR_LOST as 7 (it is 8) and STOPPED_BY_OPERATOR as 8 (it is 7). I corrected the expectations against the fixtures rather than the code; no assertion was weakened or removed.
meta.json, an optional agent report.md, and the estate manifest. It never reads .env, SSH keys, tokens or credential stores.token|secret|password|credential|private_key|ssh|api_key|auth|bearer|cookie|session is replaced with [REDACTED BY DEV CONTROL - never rendered]. Nothing in today's schema matches; this exists for the day someone adds a field.bulgarctl.ACKNOWLEDGE / UNACKNOWLEDGE. No free-text parameter exists anywhere in src/app/actions.ts or src/lib/reviews.ts. It writes only to BULGAR_ROOT/dev-control/reviews/<run_id>.json — Dev Control's own namespace, never a supervisor file. If that directory is not writable the UI says ACKNOWLEDGE UNAVAILABLE - NOT WRITABLE rather than failing silently.^[0-9]{8}T[0-9]{6}Z-[0-9a-z]{4}$ before any path is constructed, in both the read and the write path. I exercised this against the live server: op=MERGE, op=rm -rf /, run_id=../../etc/passwd and run_id=../../../../tmp/pwned were all refused, and afterwards exactly one legitimate acknowledgement file existed and nothing had been written outside the reviews directory.AGENT_FAILED run still says AGENT_FAILED, keeps its flags, and stays on the Review page. It only leaves the "open" column on Home. Verified in rendered output.robots: { index: false, follow: false, nocache: true }.src/lib/server-guard.ts throws if a state-reading module is ever pulled into a browser bundle.The phone is the primary device, so it is the default and the desktop is the media query.
env(safe-area-inset-bottom) padding for the iOS home indicator. At ≥760px it becomes a sticky top bar.viewportFit: 'cover', width=device-width, -webkit-text-size-adjust: 100%.repeat(auto-fit, minmax(…)) — two columns at 320px, more as space allows. No fixed widths and no tables anywhere.overflow-wrap: anywhere on every value cell and both <pre> blocks, because branch names and 40-character SHAs are the longest strings on screen.title attribute is invisible to a thumb. When I found gate provenance on the Estate page living only in a tooltip, I moved it into visible text (test, lint, build ← 20260825T175015Z-7980, linked). No fact required to interpret a status is tooltip-only.<form method="POST"> with hidden inputs, so it works with JavaScript disabled or still loading. I exercised it that way over plain HTTP POST.The brief asked me to *"ensure a run with gates {} shows NOT_RUN rather than blank."* estate.ts, which I was told to read first and not build a parallel vocabulary against, documents the opposite default: *"A missing gate is NOT_PRESENT only when the supervisor said so. If we simply cannot see it, that is UNKNOWN."*
The fixtures decide it. Run …134831Z-3740 has all four gates written explicitly as NOT_RUN. So the supervisor does write NOT_RUN when it knows — which means an absent key is a materially different fact from a NOT_RUN key, and flattening the two would throw away information.
What I implemented (displayGate):
exit_reason === 'RUNNING' → `NOT_RUN`. Literally true; the gate has not run yet;This satisfies the intent — never blank, never `PASS`, always one of the six words — and is tested for all six gates: {} fixtures. It differs from the literal instruction for the four terminated ones (4fb5, 6d01, 0e2e, 13f6), which read UNKNOWN rather than NOT_RUN.
If you want the literal reading instead, it is a three-line change in `displayGate` and one test expectation. I did not make that call silently.
src/lib/server-guard.ts, a runtime check. This is genuinely weaker than the real package, which fails at build time. Worth installing server-only on the worker.import … from './estate' fails with ERR_MODULE_NOT_FOUND. Compiled to CommonJS into .test-build/ instead (tsconfig.test.json), which needs no experimental flags and is deterministic.MODULE_NOT_FOUND. Used an explicit glob..test-build/fixtures, which does not exist. Resolved from process.cwd().next lint is removed in Next 16, so it resolved to next <dir> and died with *"Invalid project directory provided, no such directory: /work/lint"*. That is a broken gate, not a passing one. Replaced with eslint . and a flat eslint.config.mjs. It immediately found two real errors in my own code.react-hooks/purity. Correct catch: an elapsed time that shifts between re-renders is exactly the unstable number this app exists to avoid. Moved the clock into loadRun in the server layer.bulgarctl.report.md, rendered as escaped plain text, size-capped at 256 KiB./stats ends with a section naming each one and why it is absent, so the absence reads as a decision rather than an oversight.'use client' component, Nav.tsx, purely for active-tab highlighting. It touches no supervisor data.Stated plainly, because each is a field I wanted and could not have:
UNKNOWN and no bar is drawn.PASS/FAIL only. "369 tests" appears in the manifest notes for bulgar-dna as prose; it is not a field and is not counted.agent: AGENT_FAILED is a bare word with no reason, no exit code and no log pointer. This is the single most valuable field the supervisor could add.preview: RUNNING | NOT_STARTED. Dev Control links to the port; it does not fetch it.base_sha and remote_sha. When they are equal we can say "the branch did not move"; we cannot say how large a diff is when they differ, and we cannot see local-only commits at all.local_checkpoint: DONE with remote_checkpoint: NOT_RUN means committed work exists somewhere on the worker, but no path, worktree or commit SHA is recorded. /review lists these under *"Committed locally, never pushed"* and says exactly that.started and ended. A run that hangs cannot be distinguished from one that is working, beyond how long it has been going.estate.ts as outranking the literal brief. Three-line change if you disagree.0.0.0.0. Anyone who can reach the port can read every run, every branch name, and the acknowledge action. Nothing sensitive is rendered, but nothing is gated either.BULGAR_ROOT/dev-control/ on first request via mkdir -p. Confirm you are happy with Dev Control creating a directory inside /srv/bulgar, and that dev-control/ is a name the supervisor will never claim.Host header plus the recorded preview_port. That is correct when Dev Control is served by the same worker that publishes previews. Behind a proxy on a different host, the links will point at the proxy. Nothing is guessed — but confirm the deployment shape.server-guard.ts for the real build-time guarantee.jsx: react-jsx, the next plugin, and .next/types in include). Standard Next 16 behaviour, but it is a change I did not author.next lint → eslint . with a new eslint.config.mjs. It was broken before; confirm the rule set is the one you want.All four green on this repository, plus install:
| gate | command | status |
| install | npm ci | PASS |
| test | tsc -p tsconfig.test.json && node --test '.test-build/tests/*.test.js' | PASS — 36/36 |
| typecheck | tsc --noEmit | PASS |
| lint | eslint . | PASS — 0 errors, 0 warnings |
| build | next build | PASS — 7 routes, all ƒ (dynamic) |
Tests added: tests/honesty.test.ts (32), tests/actions.test.ts (4), tests/helpers.ts. No existing test was weakened or deleted; the scaffold had none.
Hard rules observed: no git push, no network access for state, no production anything, no deployment, no other repository touched, no test weakened.
The work is left UNCOMMITTED in the working tree, on branch agent/bulgar-dev-control/20260825T192754Z-0ed7, base 10c3edd. The brief states that a supervisor handles all git, so I did not create a commit and did not push. git status is clean of staged changes; everything is either modified or untracked and ready for the supervisor's local checkpoint.
Changed: .gitignore, README.md, package.json, src/lib/estate.ts, tsconfig.json (the last modified by next build, not by me — see §11.6). Added: eslint.config.mjs, tsconfig.test.json, AGENT-REPORT.md, src/app/**, src/components/**, src/lib/{derive,load,manifest,read,reviews,server-guard}.ts, tests/**.
.gitignore additions: .test-build, fixtures/dev-control — both runtime artefacts.