diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md
index eb8d48d5..40d1ac08 100644
--- a/.claude/rules/testing.md
+++ b/.claude/rules/testing.md
@@ -124,6 +124,18 @@ layer per capability," never "a seed per thing":
- **Manual-only registry** — OS-bound checks (hotkey delivery, real
clipboard, tray) listed explicitly with reasons, never silently
absent (see goal 0010's enforcement).
+- **Dev-loop timing checks** — a non-seed instance of the same manual-
+ only discipline, outside goal 0010's seed/NodeType registry (that
+ machinery is keyed to seeded artifacts; this isn't one). Goal 0029's
+ BuildIdentityBadge third state (amber `DEV · go-stale`) depends on a
+ real `wails3 dev` rebuild wedging or running slow — CI has no live
+ file watcher or real Go recompile-and-relaunch cycle to reproduce
+ that timing deterministically. The pure comparison logic
+ (`isGoSourceStale`, `frontend/src/app/goLiveness.ts`) is unit-tested
+ directly (`goLiveness.test.ts`); the full live behavior — an actually
+ wedged watcher flipping the badge amber in a real window — stays a
+ manual desktop-mode check (`.claude/skills/run-mill`), named here
+ rather than silently absent.
From the UX point of view the seed layer stays privileged — it's the
one a human can SEE working — but correctness under change belongs to
diff --git a/Taskfile.yml b/Taskfile.yml
index cd215ac5..3700edde 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -127,6 +127,26 @@ tasks:
# always begins from exactly one live instance. `|| true`: a clean
# start (no orphan) is the normal case, not an error.
- pkill -f "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.APP_NAME}}" || true
+ # Same failure mode, the vite side (goal 0029, hit live tonight):
+ # a supervisor killed via SIGHUP can leave the vite dev server
+ # itself still bound to VITE_PORT (strictPort: true in
+ # vite.config.ts), so the NEXT `wails3 dev` fails to bind instead
+ # of just starting -- kill whatever's still listening first.
+ - lsof -ti :{{.VITE_PORT}} | xargs kill -9 2>/dev/null || true
+ # Disk-space warning (goal 0029, hit live tonight): a Go rebuild
+ # can fail or wedge SILENTLY when the disk fills up mid-link, and
+ # the build-identity badge has no way to tell "wedged" apart from
+ # "idle" from the outside -- so warn loudly up front instead.
+ # Non-blocking by design (never refuse to start the dev loop over
+ # this) -- df -Pk's portable single-line-per-filesystem format
+ # avoids the wrapped-output parsing footgun plain `df` has on
+ # long device paths.
+ - |
+ FREE_KB=$(df -Pk . | awk 'NR==2 {print $4}')
+ if [ -n "$FREE_KB" ] && [ "$FREE_KB" -lt 2097152 ] 2>/dev/null; then
+ FREE_GB=$(( FREE_KB / 1024 / 1024 ))
+ echo "⚠️ Disk space low (~${FREE_GB}GB free) -- Go rebuilds can fail or wedge silently, and BuildIdentityBadge's go-stale detection can't tell that apart from a healthy idle watcher. Run 'go clean -cache' to free space."
+ fi
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
setup:docker:
diff --git a/docs/SPEC.md b/docs/SPEC.md
index d2a7e1fc..878b08b5 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -3933,6 +3933,30 @@ recorded as a real design input (`OPEN`), never silently dropped.
moves cleanly from badge-DETECTION (which couldn't distinguish
"committed forward" from "orphaned binary" anyway) to Taskfile
PREVENTION (the sweep above) — prevention over false-alarm.
+ **Update (goal 0029, 2026-08-12): a third badge state closes the
+ remaining honesty gap — DEV·live proved the FRONTEND was live, never
+ the GO side.** Two real incidents in one night: a disk-full
+ `wails3 dev` rebuild wedged silently with the badge still green, and
+ separately a slow watcher cycle left the running binary 15 commits
+ behind while the badge stayed green — the owner debugged a working
+ Settings feature as broken because of it. Amber **`DEV · go-stale`**
+ (tooltip/label: "Go changes not yet in this binary — restart task
+ dev") now renders whenever `internal/**/*.go`'s newest mtime has
+ outlived `BuildInfo.BuiltAt` (the running binary's own executable
+ mtime, `settingsservice_buildinfo.go`) by more than a 30s grace
+ window — long enough to absorb a normal save-triggered rebuild
+ (including the ~20s bindings-regen path) without flapping. The
+ comparison input is served by a vite dev-only middleware computing
+ the mtime AT REQUEST TIME (`vite.config.ts`'s `goLivenessPlugin`,
+ `/__mill/go-source-mtime`) — chosen over a task-dev-written heartbeat
+ file (the goal's other candidate) because it needs no new watcher
+ process of its own to itself go stale. Deliberately Go-source-mtime,
+ never git HEAD, repeating goal 0019's own lesson: a docs-only or
+ frontend-only commit never touches a working-tree file's mtime, so it
+ can't move this comparison. Paired dev-loop guards from the same
+ incidents: `task dev`'s start sweep also clears an orphaned vite-port
+ listener (`lsof -ti :9245`), and a non-blocking pre-start disk check
+ warns (never blocks) below 2GB free, naming `go clean -cache`.
## 9.5 Platform kernel & extension contract
diff --git a/docs/goals/0029-dev-liveness-honesty.md b/docs/goals/0029-dev-liveness-honesty.md
deleted file mode 100644
index f4f2ffb8..00000000
--- a/docs/goals/0029-dev-liveness-honesty.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# 0029 — Dev-liveness honesty: the DEV·live badge must not vouch for a dead watcher
-
-## Goal
-The green DEV·live badge claimed liveness twice while the Go binary
-was stale (once wedged by disk-full, once 15 commits behind — the
-owner's "can't toggle" Settings report was this, not a bug). Goal
-0019's "DEV wins unconditionally" traded away Go-rebuild honesty for
-no-false-alarms; both scalps prove the trade needs a third state.
-
-## Plan
-1. [ ] Research-first (small): the cheapest honest Go-liveness signal
- in dev — candidates: the binary self-reports its build time via
- GetBuildInfo (exists) and the frontend compares against the
- NEWEST mtime of internal/**/*.go at bundle-serve time (vite can
- compute at request time in dev middleware); or task dev writes a
- heartbeat file the binary's own watcher-restart updates. Must NOT
- false-alarm on docs-only commits (goal 0019's original trap) —
- compare against Go-source state only, never git HEAD.
-2. [ ] A third badge state: green DEV·live (both live), amber
- DEV·go-stale ("Go changes not yet in this binary — the watcher
- may be wedged; restart task dev"), red STALE unchanged. The amber
- text names the remedy.
-3. [ ] Dev-loop guards from tonight's incidents: the task dev
- start-sweep also clears the orphaned vite port (lsof -ti :9245);
- a pre-build disk-space check (< 2GB free → loud warning naming
- `go clean -cache`, the recurring silent killer).
-4. [ ] E2e where testable; manual-only registry for the rest.
-
-## Acceptance
-A wedged watcher shows amber within one poll interval while docs-only
-commits stay green; the owner never again debugs a working feature
-against a stale binary.
diff --git a/docs/goals/BACKLOG.md b/docs/goals/BACKLOG.md
index 7fc95877..f02347e7 100644
--- a/docs/goals/BACKLOG.md
+++ b/docs/goals/BACKLOG.md
@@ -77,10 +77,19 @@ this pipeline and on this code)**
verdict recorded in SPEC §3; both literal `/Users/ali` paths
de-literalized. Second lint pass (gocritic/prealloc/contextcheck/
sqlclosecheck) named as explicit future work, not done here.
-7. [ ] [0029 — Dev-liveness honesty](0029-dev-liveness-honesty.md) —
- the DEV·live badge's Go-liveness blind spot, now having claimed a
- second scalp (ADR-0035's Consequences note); a third badge state
- (amber DEV·go-stale) not yet built.
+7. [x] [0029 — Dev-liveness honesty](archive/0029-dev-liveness-honesty.md)
+ — DELIVERED 2026-08-12: third badge state amber `DEV · go-stale`
+ (`frontend/src/app/goLiveness.ts` + `BuildIdentityBadge.tsx`),
+ comparing `BuildInfo.BuiltAt` (the running binary's own executable
+ mtime) against the newest `internal/**/*.go` mtime served by a
+ vite dev-only middleware (`vite.config.ts`'s `goLivenessPlugin`) —
+ chosen over a task-dev-heartbeat-file candidate since it needs no
+ new watcher process; deliberately Go-source-mtime, never git HEAD,
+ so it can't repeat goal 0019's false-alarm. `task dev`'s start
+ sweep also clears an orphaned vite port and warns (non-blocking)
+ below 2GB free disk. Pure comparison unit-tested
+ (`goLiveness.test.ts`); the full live-wedge behavior entered
+ `.claude/rules/testing.md`'s manual-only note.
8. [x] [0030 — Node standard](archive/0030-node-standard.md) —
DELIVERED 2026-08-12: `.claude/rules/node-standard.md` (8-item
checklist, citing n8n's community-node/UX/error-handling
diff --git a/docs/goals/archive/0029-dev-liveness-honesty.md b/docs/goals/archive/0029-dev-liveness-honesty.md
new file mode 100644
index 00000000..06e3d098
--- /dev/null
+++ b/docs/goals/archive/0029-dev-liveness-honesty.md
@@ -0,0 +1,69 @@
+# 0029 — Dev-liveness honesty: the DEV·live badge must not vouch for a dead watcher
+
+## Goal
+The green DEV·live badge claimed liveness twice while the Go binary
+was stale (once wedged by disk-full, once 15 commits behind — the
+owner's "can't toggle" Settings report was this, not a bug). Goal
+0019's "DEV wins unconditionally" traded away Go-rebuild honesty for
+no-false-alarms; both scalps prove the trade needs a third state.
+
+## Plan
+1. [x] Research-first (small): the cheapest honest Go-liveness signal
+ in dev — candidates: the binary self-reports its build time via
+ GetBuildInfo (exists) and the frontend compares against the
+ NEWEST mtime of internal/**/*.go at bundle-serve time (vite can
+ compute at request time in dev middleware); or task dev writes a
+ heartbeat file the binary's own watcher-restart updates. Must NOT
+ false-alarm on docs-only commits (goal 0019's original trap) —
+ compare against Go-source state only, never git HEAD.
+ **Chosen: candidate 1 (binary mtime vs. vite-middleware mtime)** —
+ the binary already self-reports (`GetBuildInfo` existed; added
+ `BuiltAt`, its own executable's mtime, since `vcs.time` is pinned to
+ the last COMMIT and never moves across an uncommitted `wails3 dev`
+ relink). The heartbeat-file candidate would need teaching a
+ third-party supervisor (`wails3 dev`/`atterpac/refresh`) to write a
+ file on rebuild — no such hook exists. Two numbers, one comparison,
+ no new watcher process to itself go stale.
+2. [x] A third badge state: green DEV·live (both live), amber
+ DEV·go-stale ("Go changes not yet in this binary — the watcher
+ may be wedged; restart task dev"), red STALE unchanged. The amber
+ text names the remedy.
+ Built: `frontend/src/app/goLiveness.ts` (`isGoSourceStale`,
+ `useGoSourceStale`, a 30s grace window absorbing a normal
+ save-triggered rebuild incl. the ~20s bindings-regen path),
+ `frontend/src/app/BuildIdentityBadge.tsx` (amber `Label
+ variant="attention"`, label + title both name the remedy),
+ `frontend/vite.config.ts` (`goLivenessPlugin`, dev-only
+ `/__mill/go-source-mtime` middleware, request-time mtime walk of
+ `internal/**/*.go` — never git HEAD, so a docs/frontend-only commit
+ can't move it), `internal/services/settingssvc/settingsservice_buildinfo.go`
+ (`BuildInfo.BuiltAt`).
+3. [x] Dev-loop guards from tonight's incidents: the task dev
+ start-sweep also clears the orphaned vite port (lsof -ti :9245);
+ a pre-build disk-space check (< 2GB free → loud warning naming
+ `go clean -cache`, the recurring silent killer).
+ Built in `Taskfile.yml`'s `dev` task, both non-blocking. Verified
+ live against the actual tight-disk conditions this session ran
+ under (~1.8GB free triggered the real warning text).
+4. [x] E2e where testable; manual-only registry for the rest.
+ The pure comparison (`isGoSourceStale`) is unit-tested directly
+ (`frontend/src/app/goLiveness.test.ts`, 6 cases incl. the
+ docs-commit-never-moves-it case). The full live behavior (an
+ actually wedged watcher flipping a real window's badge amber) is
+ impractical to reproduce deterministically in CI — entered in
+ `.claude/rules/testing.md`'s manual-only note with its reason,
+ pointing at `.claude/skills/run-mill` for the manual check.
+
+## Acceptance
+A wedged watcher shows amber within one poll interval while docs-only
+commits stay green; the owner never again debugs a working feature
+against a stale binary.
+
+**Met 2026-08-12.** The comparison polls every 5s
+(`GO_LIVENESS_POLL_MS`) so a genuinely wedged watcher (source mtime
+outliving `BuiltAt` past the 30s grace) surfaces amber within one poll
+interval of that threshold; the comparison only ever reads
+`internal/**/*.go` mtimes (never git HEAD, never the frontend tree), so
+a docs-only or frontend-only commit structurally cannot move it —
+covered by `goLiveness.test.ts`'s dedicated case. `docs/SPEC.md` §3.8's
+build-identity entry documents the third state and mechanism choice.
diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/dataevent/index.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/dataevent/index.ts
new file mode 100644
index 00000000..18a72d4a
--- /dev/null
+++ b/frontend/bindings/github.com/alicoding/mill/internal/services/dataevent/index.ts
@@ -0,0 +1,6 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+export type {
+ Changed
+} from "./models.js";
diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/dataevent/models.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/dataevent/models.ts
new file mode 100644
index 00000000..0db0128c
--- /dev/null
+++ b/frontend/bindings/github.com/alicoding/mill/internal/services/dataevent/models.ts
@@ -0,0 +1,12 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+/**
+ * Changed is the live-sync event payload: which kind of entity changed
+ * (e.g. "workflow", "request", "list", "mcpserver", "decision",
+ * "execenv", "guardrail-rule", "run") and its ID.
+ */
+export interface Changed {
+ "entity": string;
+ "id": string;
+}
diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/mcpsvc/index.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/mcpsvc/index.ts
index 86b42909..a507ed0a 100644
--- a/frontend/bindings/github.com/alicoding/mill/internal/services/mcpsvc/index.ts
+++ b/frontend/bindings/github.com/alicoding/mill/internal/services/mcpsvc/index.ts
@@ -2,7 +2,6 @@
// This file is automatically generated. DO NOT EDIT
export type {
- DataChanged,
MCPWriteActivity,
MCPWriteRequest,
MCPWriteResolved
diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/mcpsvc/models.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/mcpsvc/models.ts
index d26df445..79da4ae3 100644
--- a/frontend/bindings/github.com/alicoding/mill/internal/services/mcpsvc/models.ts
+++ b/frontend/bindings/github.com/alicoding/mill/internal/services/mcpsvc/models.ts
@@ -1,17 +1,6 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
-/**
- * DataChanged is the live-sync event (docs/adr/0025): emitted after
- * any MCP-driven mutation so an open Mill window refreshes what the
- * LLM just changed -- §1's what-you-see-is-what-I-see thesis running
- * in both directions.
- */
-export interface DataChanged {
- "entity": string;
- "id": string;
-}
-
/**
* MCPWriteActivity is pushed for a resolved (denied/cancelled/expired,
* or approved-but-failed) MCP write so it's no longer traceless
diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/settingssvc/models.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/settingssvc/models.ts
index 467c2e9a..fb224b12 100644
--- a/frontend/bindings/github.com/alicoding/mill/internal/services/settingssvc/models.ts
+++ b/frontend/bindings/github.com/alicoding/mill/internal/services/settingssvc/models.ts
@@ -40,6 +40,21 @@ export interface BuildInfo {
* genuinely knows.
*/
"Server": boolean;
+
+ /**
+ * BuiltAt is the unix-millis mtime of THIS PROCESS'S OWN executable
+ * file -- when it was actually linked, not when its source was last
+ * committed. Deliberately not vcs.time (goal 0029, dev-liveness
+ * honesty): vcs.time reflects the last COMMIT's timestamp, which
+ * stays frozen across every `wails3 dev` relink of an uncommitted
+ * change (vcs.modified just goes true) -- exactly the case a wedged
+ * or slow rebuild watcher needs distinguished from "healthy." A
+ * binary's own mtime moves on every real relink regardless of git
+ * state, so the frontend can compare it against the newest mtime of
+ * internal/** /*.go (vite's dev-only middleware, vite.config.ts) to
+ * tell "rebuilt after this save" from "still running the old one."
+ */
+ "BuiltAt": number;
}
/**
diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts
index 5b9a3cb5..026bf919 100644
--- a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts
+++ b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts
@@ -5,6 +5,9 @@
// @ts-ignore: Unused imports
import type { Events } from "@wailsio/runtime";
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore: Unused imports
+import type * as dataevent$0 from "../../../../alicoding/mill/internal/services/dataevent/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import type * as executionsvc$0 from "../../../../alicoding/mill/internal/services/executionsvc/models.js";
@@ -22,7 +25,7 @@ declare module "@wailsio/runtime" {
"hotkey-activity": triggersvc$0.HotkeyActivity;
"mcp-write-activity": mcpsvc$0.MCPWriteActivity;
"mcp-write-approval": mcpsvc$0.MCPWriteRequest;
- "mill-data-changed": mcpsvc$0.DataChanged;
+ "mill-data-changed": dataevent$0.Changed;
"mill-navigate": string;
"time": string;
}
diff --git a/frontend/src/app/BuildIdentityBadge.tsx b/frontend/src/app/BuildIdentityBadge.tsx
index dd96840b..528a0453 100644
--- a/frontend/src/app/BuildIdentityBadge.tsx
+++ b/frontend/src/app/BuildIdentityBadge.tsx
@@ -1,6 +1,7 @@
import { Label } from '@primer/react'
import { SettingsService } from '../shared/bindings'
import type { BuildInfo } from '../shared/bindings'
+import { useGoSourceStale } from './goLiveness'
import styles from './App.module.css'
// Extracted from App.tsx along the 500-line seam (goal 0019). ONE rule
@@ -25,6 +26,11 @@ import styles from './App.module.css'
// by an old binary -- docs/SPEC.md §3.8).
const isDevBuild = import.meta.env.DEV
+// Third badge state (goal 0029, dev-liveness honesty): amber
+// `DEV · go-stale` when Go source has moved since this binary was
+// built but the rebuild hasn't landed yet -- the comparison logic and
+// its own reasoning live in ./goLiveness (pulled out so it can be
+// unit-tested without dragging @primer/react's CSS into Vitest).
export function BuildIdentityBadge({ buildInfo }: { buildInfo: BuildInfo | null }) {
// Go's own build tag (BuildInfo.Server), not a window-global sniff --
// `'_wails' in window` is true in server-mode browser tabs too (the
@@ -32,6 +38,9 @@ export function BuildIdentityBadge({ buildInfo }: { buildInfo: BuildInfo | null
// INSTALLED/SERVER split until goal 0021's dogfooding caught it.
const isNativeWebview = buildInfo != null && !buildInfo.Server
const binaryHead = buildInfo?.Revision ? buildInfo.Revision.slice(0, 7) : ''
+ // Called unconditionally (rules of hooks) -- it no-ops outside a dev
+ // build or before buildInfo has arrived, per its own guard.
+ const goSourceStale = useGoSourceStale(buildInfo?.BuiltAt)
// DEV wins over the stale comparison, deliberately (fixed 2026-08-11
// after it false-alarmed on every commit): under `task dev`, vite
@@ -45,6 +54,21 @@ export function BuildIdentityBadge({ buildInfo }: { buildInfo: BuildInfo | null
// commit as the binary. Dev-orphan windows are handled by prevention
// now (the Taskfile pkill sweep, SPEC §3.8), not by this badge.
if (isDevBuild) {
+ // Third state (goal 0029): Go source has moved since this binary
+ // was linked and no rebuild has landed within the grace window --
+ // named remedy in both the visible label and the tooltip, since
+ // amber (unlike green/red) isn't self-explanatory at a glance.
+ if (goSourceStale) {
+ return (
+
+ )
+ }
return (