agentleFS
maister-devmaister-dev/maister/web/CLAUDE.md

maister / web

CLAUDE.md10 starsChanged 6 months agoMITView on GitHub

Read ../CLAUDE.md first. It holds the product spine, locked architectural decisions (ACP-driven HITL + checkpoint/resume, durable canonical SSE, typed MaisterError, concurrency cap, multi-executor via ACP, Flow Engine v2 plugin model) and the out-of-POC list. This file is the Web/Next.js slice only. Agent processes live in ../supervisor/, NOT here. This is the web tier of MAIster: UI + Route Handlers + server actions + Drizzle DB access + SSE bridge to supervisor/. Agent processes (claude, codex) and ACP sessions live in…

The file

Show its rules and text

We split this CLAUDE.md into the rules it gives an agent

19 rules. About 0 are common, 7 are distinctive, and 12 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈7

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 12

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Do not: add other component libraries (no shadcn/ui, no MUI, no Chakra).

    line 38
  2. Distinctive:

    No URL-prefix routing. Locale is purely cookie-driven.

    line 118
  3. Very specific:

    Prefer awaiting the real condition over polling for a side effect.

    line 264
  4. Very specific:

    After the rebase onto master 0afffb50 (the ownership-residuals merge): unit 869 files / 9105 tests, 0 failures.

    line 392
  5. Distinctive:

    After the 2026-09-28 review-remediation cycle (same branch, this Mac): unit

    line 397
  6. Very specific:

    A first-hit Next-dev compile can exhaust a 10s e2e timeout: Before calling such a failure a regression, re-run the spec in isolation and …

    line 465
  7. Distinctive:

    PUT/DELETE per changed file with no navigation at all.

    line 557
  8. Very specific:

    A freshly loaded page can be in the DOM twice: Every (app) page streams behind app/(app)/loading.tsx.

    line 564
  9. Very specific:

    When the dev server is saturated that round-trip is starved long enough for the coordinator's node to give up, its run-bound facade token …

    line 772
  10. Distinctive:

    Run status is the execution axis (richer state machine).

    line 985
  11. Very specific:

    If Running/NeedsInput (worker live) → supervisor delivers ACP message → resume in-process.

    line 1015
  12. Very specific:

    If NeedsInputIdle → supervisor spawns a fresh process and restores context via session/resume on acp_session_id → reads the input artifact …

    line 1017
  13. Very specific:

    Use the @/... alias for imports rooted at web/ — never deep relative ../../...

    line 1033
  14. Very specific:

    Default to Server Components.

    line 1038
  15. Very specific:

    Default theme is dark (<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}> in app/layout.tsx).

    line 1040
  16. Distinctive:

    Tables are view-only: No inline editing, row dropdowns, or row-level mutate buttons.

    line 1088
  17. Distinctive:

    Feedback and confirmation (UI completion batch): Use the single shared feedback provider for completed mutations.

    line 1141
  18. Very specific:

    All writes to .maister/<project-slug>/runs/<run-id>/ are atomic (tmp + rename via atomicWriteJson).

    line 1174
  19. Distinctive:

    Every externally visible ACP event must be committed to the supervisor outbox before publication and projected through canonical Postgres …

    line 1176

≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
CLAUDE.md

CLAUDE.md — web/ (MAIster Web Control Plane)

Read ../CLAUDE.md first. It holds the product spine, locked architectural decisions (ACP-driven HITL + checkpoint/resume, durable canonical SSE, typed MaisterError, concurrency cap, multi-executor via ACP, Flow Engine v2 plugin model) and the out-of-POC list. This file is the Web/Next.js slice only. Agent processes live in ../supervisor/, NOT here.

What lives here

This is the web tier of MAIster: UI + Route Handlers + server actions + Drizzle DB access + SSE bridge to supervisor/. Agent processes (claude, codex) and ACP sessions live in the separate ../supervisor/ daemon — this slice is the human-facing surface and the registry/board/HITL UX. The personal librarian lives in lib/librarian/, components/librarian/, and app/api/librarian/; it keeps per-user history and delegates turn-scoped MCP access through the supervisor (ADR-185..189).

Current state: scaffolded from the official HeroUI Next.js template (heroui-inc/next-app-template). The app/, components/, config/ content is template demo material to be replaced as we implement MAIster routes.

Stack (concrete versions in package.json)

LayerChoice
FrameworkNext.js 16.4.3 (App Router)
React19.3.1
LanguageTypeScript 6.0.3, strict, @/* → ./*
UI library@heroui/react 3.2.6 + @heroui/styles
StylingTailwind CSS 4.3.3 via @tailwindcss/postcss
Variantstailwind-variants 3.3.1
ThemingLocal script-free theme provider (default dark)
LintESLint 9 flat config + Prettier
Pkg managerpnpm
Node24 (per root CLAUDE.md container target)

Do not add other component libraries (no shadcn/ui, no MUI, no Chakra). HeroUI v3 + Tailwind 4 + tailwind-variants covers all UI primitives. Main/default UI language - EN, project should be i18n-ized. RU interface support is REQUIRED

M9 additions (shipped 2026-05-29)

Design tokens (forest theme)

styles/globals.css defines the @theme block with forest-* CSS custom properties (backgrounds, surfaces, borders, text, accent, status colours). Light/dark variants switch through lib/theme.tsx, which toggles the .light / .dark class on <html> without injecting client-rendered script tags. Components import the tokens as Tailwind utilities (bg-forest-bg, text-forest-text-primary, etc.). The tokens are also referenced via the @custom-variant dark rule so dark: prefixes resolve correctly.

Authentication (Auth.js v5 credentials)

Two-file edge/Node split:

  • auth.config.ts — edge-safe (no DB). jwt / session callbacks seed id / role / mustChangePassword from user at sign-in, plus trustHost: true. Imported by proxy.ts (which builds NextAuth(authConfig)).
  • auth.ts — Node.js runtime only. @auth/drizzle-adapter + the CredentialsProvider (email + password, bcrypt), and a DB-backed jwt callback that re-reads users.role / users.mustChangePassword on every refresh and returns null (signs the user out) if the user row is gone. Re-exports auth, signIn, signOut, handlers.

web/proxy.ts protects all (app) route-group pages. Unauthenticated requests redirect to /login. API routes under app/api/ call requireSession() / requireProjectAction() from lib/authz.ts directly to return machine-readable JSON errors.

Admin bootstrap: the single default admin is seeded by migration 0005 (admin@maister.local / maister-admin, must_change_password = true) — NOT by first-user registration. register() always creates member (no admin auto-promotion → no concurrent-first-user admin race). must_change_password is enforced by the (app) layout, which redirects to /change-password until the user sets a new password (changePassword server action). The column is reusable for admin-forced resets.

RBAC model

lib/authz.ts exports:

  • requireSession() — throws UNAUTHENTICATED (401) if no session.
  • requireGlobalRole(min) — throws UNAUTHORIZED (403) if users.role is below min. Order: viewer < member < admin.
  • requireProjectRole(projectId, min) — checks project_members.role. Global admin users bypass this check and receive owner access. Order: viewer < member < admin < owner.
  • requireProjectAction(projectId, action) — convenience wrapper mapping named actions (readBoard, launchRun, createTask, answerHitl, editSettings, …) to minimum project roles via PROJECT_ACTION_MIN. M22 (ADR-053) adds readRepoFiles (member), gating the workbench git-tracked file routes — strictly above readBoard/viewer, so a viewer cannot browse source. The flow-graph view is read-only (authored layout in flow.yaml, ADR-064 — no layout write action). The workbench diff stays readBoard (viewer, run-scoped).
  • httpStatusForAuthz(code) — maps UNAUTHENTICATED→401, UNAUTHORIZED→403 for use in API route error handlers.

The projectId passed to project-scoped functions must always be a server-derived value (from a DB row), never a body field.

DB-authoritative: getSessionUser() re-reads the live users row (role + mustChangePassword + existence) from the DB on every call — the JWT supplies only the user id. A demoted/deleted user therefore loses authority on their next request, not at the 30-day JWT expiry. Never branch authorization on the cached session.user.role.

i18n (next-intl, cookie-based)

  • i18n/request.ts — locale resolution: NEXT_LOCALE cookie first, then Accept-Language header, defaulting to en.
  • messages/en.json and messages/ru.json — message catalogs.
  • In-app toggle calls the setLocale server action (sets the cookie).
  • No URL-prefix routing. Locale is purely cookie-driven.
  • Server Components use getTranslations() from next-intl/server. Client Components use useTranslations().

Route groups

app/
  (auth)/          # Public: /login — no session required.
    login/
  (app)/           # Protected: session required (proxy redirect).
    page.tsx        # Portfolio home (/)
    projects/
      page.tsx      # Project list (/projects)
      new/
        page.tsx    # Add project form (/projects/new)
      [slug]/
        page.tsx    # Project board (/projects/[slug])
        tasks/
          new/
            page.tsx  # Task creation (/projects/[slug]/tasks/new)

Screen reference. Each user-facing screen and the shared chrome are documented screenshot-free under ../docs/screens/ — see ../docs/screens/README.md for the IA map, per-doc template, and index. Update the matching screens/* doc when you change a screen.

Scope note

Auth.js/NextAuth, project RBAC, the 6-column board design, and the credentials login flow are M9 additions that go beyond the original POC "out of scope" list in ../CLAUDE.md. They were sanctioned by the user as part of this milestone.

Known M9 deferrals (do not implement without explicit instruction):

  • The PRs board tab placeholder was superseded by a real PR-state surface: board.ts's prState/prHasConflicts DTO fields, populated by the pr_state_scan job (ADR-140), drive a PR-state chip on Done/InDelivery/ OnReview cards. (The MCPs board tab is the real M27 project MCP catalog; the platform MCP catalog is now reachable at admin /mcps, and the Inbox nav now reaches a real /inbox — both unlocked by the web-shell nav-unlock work, which also moved supervisor status to the footer only and added a Cmd/Ctrl+K launch shortcut + a runners-readiness rail block.)
  • The projects table now has repo_url and provider columns (ADR-025). It still does not have lang, description, tags, or mcps columns — those design ideas remain omitted from the schema until a migration is added.
  • The settings panel (/projects/[slug]/settings) is read-only; there is no settings-write API route in M9.
  • The board's "In Delivery" stage is an approximation computed in web/lib/board.ts from recently-merged run status — not a persisted board column.
  • Admin user management covers global users at /admin/users (activate pending registrations, disable/re-enable accounts, change global roles, reset passwords). Project member management shipped after M9: the project members panel adds/changes-role/removes members (web/lib/project-members.ts, web/app/api/projects/[slug]/members/*, ADR-062).

Scripts

pnpm dev      # next dev — http://localhost:3000
pnpm dev:clean # remove .next, then next dev — use when dev cache keeps stale UI
pnpm clean    # remove .next
pnpm build    # remove .next, then next build
pnpm start    # next start (after build)
pnpm lint     # eslint --fix
pnpm test:integration:ab        # in-process A/B acceptance, real supervisor/Postgres
pnpm test:integration:ab --shard 1/2 # first explicit web half; 2/2 runs the complement
pnpm test:integration:isolation # serial production boot, real sandbox driver, invocation cleanup
pnpm install-package          # ADR-088: install a multi-flow package (all flows + bundle)
pnpm validate-authored-flow   # validate a portable authored Flow package dir
pnpm import-flow-package-draft # import a package as an inert authored draft
pnpm export-authored-flow     # export an authored Flow to a portable dir
pnpm install-authored-flow-package # bridge export as untrusted installed package

pnpm typecheck runs tsc --noEmit (noEmit: true in tsconfig). The web A/B runner accepts --shard i/n; CI uses 1/2 and 2/2 as complete, disjoint file lists. The isolation runner does not accept this flag.

The test-only MAISTER_TEST_ISOLATION selector is owned by test-support/process-isolation.ts; its platform/capability and implemented Linux namespace semantics are in ../docs/configuration.md#test-only-isolation-selector. Linux development evidence and hosted qualification are separate gates in ../docs/system-analytics/test-infrastructure.md. No product config/API/schema change or production separate-user guarantee is implied.

Test database contract

Database-backed integration and E2E tests use test-support/pg-container.ts. It is the only web Testcontainers constructor, owns pgvector image selection, main/Brain migration order, and pool-before-container teardown. Build/unit work must not require Docker; integration/E2E require it explicitly.

Supervisor contract in tests

baseUrl() in lib/supervisor-client.ts THROWS CONFIG when MAISTER_SUPERVISOR_URL is unset under a test runner (ADR-167). It used to fall through to the dev port, so any suite reaching the real transport silently drove whatever supervisor was listening — and was green only while one happened to be.

A suite reaches that transport whenever its graph carries owned prompts: runFlow only takes the execution-host branch when hasOwnedPrompts is true and the run is Running/NeedsInput, so a graph of cli/check nodes or artifact_required gates needs nothing, while one with ai_coding/judge/ orchestrator/consensus nodes or ai_judgment/skill_check gates needs a host. The agent resume path and the system_sweep event-plane activation reach it too.

Such a suite MUST mock the health seam — it does not need a real supervisor:

vi.mock("@/lib/supervisor-client", async (importOriginal) => ({
  ...(await importOriginal<typeof import("@/lib/supervisor-client")>()),
  checkSupervisorHealth: async () => readySupervisorHealth(),
  getExecutionHostCapabilities: async () => readyExecutionHostCapabilities(),
}));

with the two helpers from test-support/supervisor-health-fixture.ts. Only a suite that genuinely exercises supervisor behaviour should reach for test-support/real-supervisor.ts instead. Setting MAISTER_SUPERVISOR_URL globally is NOT the fix: it would disarm the guard for every suite, which is the hijack it exists to prevent.

Route suites: load the route in a hook, not in a test

await import("../route") pulls a large module graph — 700-800 ms for the HITL respond and scratch-recover routes. Done lazily inside a per-test helper, that cost lands on whichever case ran first, inside vitest's 5 s default. On a loaded machine it blows the budget, and the damage does not stop at one red case: a timed-out test's request keeps running, so its later calls hit module-level spies whose mockImplementation now belongs to the NEXT case. That is how the respond suite produced ["queued","queued","delivered","delivered"] for an assertion expecting two entries — a false failure in a test that was itself correct.

Load the module once in beforeAll (its own budget, outside every test) and have helpers read the cached handle. After the hoist neither suite has a case over 100 ms.

Prefer awaiting the real condition over polling for a side effect. expect.poll defaults to a 1 s budget, which is ample idle and too tight under contention; where a fake already offers a hook on the awaited call, signal a deferred from it instead. Note that fake-execution-host's runCommand records a call before running its behaviour, so a signal sent from setPromptBehavior fires with the call already in fake.calls — and unlike an onCall hook it cannot leak, since beforeEach reinstalls the default behaviour.

Suite baselines and known-flaky specs

Compare failure SETS, never counts — two runs can tie while one spec traded places with another. As of 2026-09-03 on main + ADR-165:

  • unit — 0 failures.
  • integration — 0 failures, except the two-case lib/runs/__tests__/dirty-resolution-race.integration.test.ts pair, which flakes under parallel load and passes 4/4 in isolation.

Measured 2026-09-22 (ADR-179 branch REBASED onto master c4216cd5, this Mac). unit 819 files / 8480 tests, 0 failures; integration 500 files / 4414 tests, 1 failure (25m); supervisor 26 files / 239 tests, 0 failures. The lane totals moved because master gained 22 commits, not because this branch added files. The one failure was lib/execution-host/__tests__/deliverer.integration.test.ts "D3: runtime-object reserve, upload, and delete use the assignment-bound command ledger" — a NEW name, not the documented dirty-resolution-race pair. Classified load-sensitive, not a regression: it passes 4/4 in four consecutive isolated runs (4 tests each), and the branch's entire lib/execution-host/ + fake diff is +78/-0 — pure insertion adding only checkEnvRefs/EnvRefPresence/EnvRefs, with no line touching the runtime-object or deliverer path. It reproduced identically after the rebase, so it is now a standing load-sensitive name, not a branch artefact: treat a recurrence as load, and re-run the file idle before filing it. Root-caused and fixed 2026-09-23: an ACK seal never makes the manager's runtime-object row available — only the projected canonical event does — and D3 deleted straight after publishing, so under load the delete was refused from pending. The case now waits for the projected state; it is no longer a standing name.

Measured 2026-09-26 (ADR-182 steering branch on master 8ff196d4, this Mac). unit 849 files / 8788 tests, 0 failures; integration 528 files / 4652 tests (1 skipped: the opt-in host-span-load); supervisor 76 files / 724 tests, 0 failures (unit 45 / 455, integration 31 / 269). The full lane ran at load 75-240 and was killed (exit 144) after 526 files had reported, so its summary never printed: count the per-file lines, and run what is missing — here lib/flows/graph/__tests__/prompt-owners.integration.test.ts (58/58, 23.5 min alone) and host-span-load. Under that load 8 tests in 4 files were red, and every one passed re-run idle: lib/agents/__tests__/prompt-owners "owner-agent-budget … resumes its exact interrupted source" (5 cases, each failing at ~15.8 s — just past a 15 s wait — and green at 20-77 s idle), projection-worker AT-03, execution-ab-process-cleanup O2 and execution-ab-partitions P4. Treat those names as load-sensitive: re-run the file idle before filing a recurrence. After the same day's review fix round: unit 850 files / 8792 tests, 0 failures; supervisor 76 files / 730 tests, 0 failures; the full integration lane was NOT re-run — the 21 files the round touched or depends on ran green (the steering, scratch-placement, local-package-assistant, migration, ext-message, recovery, deliverer, ledger and admission suites), and ledger.integration's unhandled "pool after end" rejection is the one 8ff196d4 also shows. After the rebase onto master b8003790 (the git-panel merge): unit 863 files / 8992 tests, 0 failures; supervisor 76 files / 730 tests with one load-only red — spawn.test.ts "emits bounded session-line events" (expected [] to deeply equal [1, 2, 3]) failed while the unit lane ran beside it and passed 5/5 alone, neither side having touched spawn.ts; the 10 steering, migration, scratch-placement, local-package-assistant, ext-message and turn-admission integration files 100/100.

Measured 2026-09-26 (ownership-residuals branch on master 881619a5, before efc7833b and 3ee5df87, this Mac). unit 867 files / 9066 tests, 0 failures; supervisor 78 files / 747 tests, 0 failures (unit 46 / 466, integration 32 / 281); mcp 6 files / 266 tests, 0 failures; integration 552 files / 4905 tests (2 files skipped). The integration lane ran at load 90-99 and 39 files were red; all 39 passed re-run together at load ~10 (499/499), so none is a regression. 31 were whole-file TestDatabaseDockerUnavailableError: container runtime probe timed out (one No host port found for host IP): every case SKIPPED in ~3 s, followed by TypeError: The "path" argument … Received undefined from the file's own teardown. That is the testcontainers probe, not a test — read a file's first error before counting its skips as failures. The other 47 cases were timeouts in 8 files: lib/flows/graph/__tests__/prompt-owners (32), execution-ab-process-cleanup (5), permission-result-failure (3), execution-ab-process-death (3), gate-permission-result, workspace-reconciler "processes a due candidate after more than one batch of quarantined paths", projection-worker AT-03 and execution-ab-partitions P3-live. New load-sensitive names for this Mac: the Docker probe timeout, workspace-reconciler's batch case, gate-permission-result and permission-result-failure (a 767 s file under load, 190 s idle). e2e m19-reconcile-gc + scratch-detail 13 passed (--workers=2).

Measured 2026-09-26 (ADR-183 outbox-pressure branch on 881619a5, this Mac). unit 867 files / 9041 tests, 0 failures; supervisor 84 files / 777 tests, 0 failures (unit 49 / 484, integration 35 / 293); integration 549 files / 4871 tests in 29.4 min on a snapshot of the Phase 4 commit — 544 passed, 2 skipped (the opt-in load suites), 3 red and all classified: app/api/runs/[runId]/recover/__tests__/route.integration.test.ts (Docker "No host port found for host IP" at container start — 11/11 re-run), orchestrator-park "re-drives a rebound orchestrator coordinator after death before its first command" (a 10 s poll at lane load 60–85 — 4/4 idle; a new load-sensitive name) and execution-ab-partitions P4 (6/6 re-run). The ledger.integration "pool after end" rejection recurs. The Phase 5 lane (same Mac, Docker Desktop saturated — setup 505 s against 19 s) had 72 files red, 67 of them TestDatabaseDockerUnavailableError: container runtime probe timed out; all 72 passed re-run (340 tests), as did the other 29 agent integration files: read the setup time before reading the failures. The opt-in R20 (MAISTER_EVENT_PLANE_LOAD=1) is RED at the default budgets because of the open-span limit — read ADR-183's "Measured limit" before filing it.

Measured 2026-09-27 (ADR-184 open-span prune, same branch, this Mac). unit 868 files / 9062 tests, 0 failures; supervisor 84 files / 782 tests, 0 failures (unit 49 / 486, integration 35 / 296); integration 550 files / 4877 tests on a snapshot of the Phase 2 commit — 546 passed, 2 skipped (the opt-in load suites), 2 red and both classified: lib/brain/__tests__/ambient (TestDatabaseDockerUnavailableError: container runtime probe timed out — 10/10 re-run) and consensus-prompt-owners "P0-5: verifier output past 1 MiB …" + "owner-consensus-verify …" at lane load 120–190 — the same load-sensitive pair the ADR-183 lane recorded (its plan's N13); the whole file passes 23/23 alone and the P0-5 case 3/3 filtered, but it went red once more beside one other file at load ~20, so treat it as a standing load-sensitive name. The Phase 1 lane's one red (execution-ab-process-cleanup O1-default, a nested-cleanup AggregateError at load ~120) passed 13/13 re-run. The final lane (the capacity fix included, 550 files / 4877 tests) had one red at load ~150 — durable-workers-concurrency "E: SIGTERM while a claim is held …" (expected ['pending', 'applied'] to include 'applying': the web drain lost the race to the load) — green 3/3 idle; a new load-sensitive name. The opt-in R20 is now GREEN at the default budgets (two 10-minute runs, ADR-184 D7): if it goes red, first check that the run was alone. A supervisor the fixture watchdog SIGKILLed ("fixture-watchdog-error … could not be inspected" as the last line of its log) was starved — by the host's load, or by itself: before 2026-09-28 nine paused producers pegged it at 100% CPU (the capacity cascade, EDGE-EVT-14), so a pegged supervisor with paused producers is a product regression, not load. After the rebase onto master 0afffb50 (the ownership-residuals merge): unit 869 files / 9105 tests, 0 failures; supervisor 84 files / 785 tests, 0 failures; integration 558 files / 4968 tests, no test failure (delegate-flow-arm failed at container start — Docker "No host port found for host IP" — and passed 19/19 re-run). After the 2026-09-28 review-remediation cycle (same branch, this Mac): unit 870 files / 9135 tests, 0 failures; supervisor 85 files / 798 tests, 0 failures (unit 50 / 494, integration 35 / 304); integration 559 files / 4984 tests, twice. On a snapshot of 56ec3da82 at load ~150, one red: the standing durable-workers-concurrency E, plus five unhandled 57P01 ("terminating connection due to administrator command") from permission-crash-boundary — its Recover case detached the resumed runFlow, which then outlived afterAll and held connections while the container stopped (fixed the same day, cf7931090: the case settles its driver and afterAll fails on a straggler). On the final tree, one red: bounded-output (a new load-sensitive name — this branch never touched it) and the ext … memory route skipped on the Docker probe timeout. All four files passed together idle (50/50), with no unhandled error.

Current local qualification, 2026-09-30 (codex/stage-ab-ci-r9, tested 175e7c57c, Darwin ARM64 / Node 24.15.0). Web units: 878 files / 9,204 tests, zero failures. Ordinary integration: 589 files / 5,206 tests qualified file by file from the actual two web shards, complete serial remainder and owning reruns; every accepted result passes with no skips. The diagnostic remainder's four failures were a proved 1,843-second macOS sleep during D2a and obsolete placement/frozen-intent seed data in crash-resume / emit-hitl; both corrected files pass, and the entire mandatory isolation CLI passes 7 files / 50 cases in 801.079 seconds with zero leaks. The two existing opt-in load suites are excluded explicitly. Supervisor units/integration: 86 files / 805 tests (50/495 unit, 36/310 integration), all passed; mandatory A/B subset 10/111. The exact original reporters, tested SHAs and production-source equivalence are recorded in the CI/R9 plan. Full source types, contracts/docs/ERD and migration checks pass; full lint has zero errors (web 769 existing warnings, supervisor four). Hosted acceptance remains open: master run 36979318143 at 5cf0076f9 fails the Node 24.15 rework control and the Intel isolation deadline. Only the independent preflight report is complete. The linked CI/R9 plan records exact jobs and artifacts.

Hosted-failure follow-up (2026-10-02, local ARM64 / Node 24.15.0): status-transition, successor-activation and permission-cap fault controls are synchronized to their intended boundaries. The complete agent-owner suite and all seven isolation suites pass 100/100 in eight files, no skips or leaks, in 1,551.167 seconds including cleanup. Independent preflight passes 1/1 in 3.932 seconds. The existing suite-owned migrated templates and fresh case clones remain unchanged; their helper controls retain the earlier 8/8 integration and 2/2 unit qualification. Cleanup logs preserve failed stages and nested causes; bounded process-identity diagnostics retain strict signaling refusal. The final real foreign-process refusal control passes. These ARM results do not explain the two hosted cleanup-only failures or establish Intel budget acceptance. Exact RED/GREEN reporters and remaining gates are in the CI/R9 plan.

Latest corrective qualification (2026-10-02, local ARM64 / Node 24.15.0): Scratch authority writers share FOR NO KEY UPDATE on runs before locking the dialog, avoiding the transcript allocator/FK deadlock without allowing concurrent writers. Two real-PG RED controls cover permission persistence and recovery queueing. Rework tests enter genuine worktree-backed Review instead of overwriting Done during directory cleanup. Final consumer suites qualify 273/273 cases; full isolation passes 50/50 in seven suites in 801.033 seconds including cleanup. Separate preflight passes 1/1 in 4.868 seconds; both isolation invocations have zero leaks and use the default Docker probe deadline. Hosted 37010985789 remains failed: its Docker initialization subphase and Intel budget remain unresolved. The CI/R9 plan owns exact RED/GREEN reports and local probe settings. At that historical checkpoint S5.2 stayed 42/45; the owner authorized proceeding next to S5.3a's Linux-driver proof.

Current S5.2 acceptance (2026-10-20): owner-pushed b8e72adfe passed both macOS Intel isolation parts (19/31 cases), both preflights, four web shards and two supervisor legs on the required Nodes, within unchanged budgets and with zero leaks. Parent stabilization is 43/45; S5.3/S5.4 remain open. Exact-source receipts and limits: CI-R9d checkpoint. This does not qualify later sources or Linux.

A first-hit Next-dev compile can exhaust a 10s e2e timeout. Before calling such a failure a regression, re-run the spec in isolation and read the RETRY time: scratch-detail.spec.ts:50 ("suggests project skills"; :57 since ADR-182) fails at 10.8s and passes on retry at 1.0s, reproducibly, on BOTH this branch and plain master (2 runs each; re-confirmed 2026-09-26 on the steering branch and 8ff196d4: 11-14 s, then ~1 s on retry — at load 245 the retry failed too). A retry that passes in a second proves the data was there and the first hit paid for compilation — the failure is warm-up, not behaviour.

  • e2e — 1 pre-existing failure in 1 spec file, enumerated below, plus one known-flaky spec. Ports 3100/7788 and the maister_e2e database are shared across worktrees; kill both ports before a run. Before filing an e2e failure as environmental, read the [WebServer] lines in the run log: a React server-component stack there is a product bug, not a test one. That is not hypothetical — it is how the ADR-168 Integrations crash below was found.

    The set, not the count (re-measured 2026-09-17, one failing case per spec unless noted). An earlier entry said "34" with no list, which is unusable: this section's own first line says to compare SETS, and a bare number cannot be diffed.

    studio-ai-assistant — it needs a real ACP turn from the test supervisor, which is infrastructure rather than a selector.

    A serial file hides tests behind its first failure. forked-package-loop is mode: "serial", so its batch failure had been SKIPPING the two tests after it — the old "(1)" undercounted by two whole tests, and both carried stale expectations of their own (an unreachable sync-notice, and a publish source selected by bare path rather than by the file:// url it is stored under). Count the SKIPS, not just the failures, before trusting a serial file's entry here.

    A control can be painted and still be unreachable. review-comments spent a long time looking like a broken selector. It was two things: the library renders a .diff-add-widget in BOTH the gutter and content cells, pushed onto the column seam by opposing translateX so they stack at one rect (the content one is on top and is what a pointer hits); and at 1280x720 the split diff pane is SEVENTY-TWO pixels wide — shell 848 → diff column 410 → file tree

    • 72 — so the widget straddled the container's overflow-hidden edge and left the hit-test tree. elementsFromPoint at a control's own centre is the cheap way to tell "wrong locator" from "nothing can click this": if it returns an ancestor, no amount of force or dispatchEvent will help.

    Each has its OWN cause — this set has no shared theme left, so triage from the failure rather than looking for a pattern. Two of them (evaluation-lab, forked-package-loop) share one: both POST an evaluation launch-batch with a hand-written recipe whose flowRefId, flowRevisionId and contract digests are placeholders, and the ADR-150 preflight refuses it with PRECONDITION -> 404. That is not fixable by adjusting the values: lab-queries.ts states the contract outright — the server resolves the recipe scaffold and "the client can never fabricate a digest that would pass preflight", so the specs have to take the scaffold from the server.

    Closed on 2026-09-17 (26 spec files, 31 cases — 3 of them tests that a serial-mode skip had been hiding), all stale expectations except three product bugs. evaluation-lab and forked-package-loop's batch test needed the launch dialog rather than a hand-written recipe: since ADR-150 the route preflights every inline recipe, and lab-queries.ts records that "the client can never fabricate a digest that would pass preflight" — so a spec that POSTs one directly can only ever get a 404. The third: getDefaultBranch probes with git -C <dir>, which resolves UPWARD — so for a directory that is not itself a repo root it described the enclosing checkout. New-empty onboarding writes the manifest before gitInit runs, so a greenfield folder inside another working tree stamped that tree's default into the manifest and the project row, for a repo then created on main. The second: listProjectAutomations sorted by a rank written as a bare integer literal, which Postgres reads in ORDER BY as an ORDINAL POSITION — ORDER BY 0 failed to analyse and the project Automations tab fell to the error boundary on every project, taking run-schedules and project-automations with it. Both had been filed as stale tests. Every test that touched that function mocked it, so the SQL was never executed. Additionally closed: platform-acp-runners · run-schedules · project-automations · studio-package-viewer · multi-run-cost-policy (2) · run-sync · execution-host-contract · project-onboarding · project-registration, and platform-agents-page, which now passes untouched. The first product bug: studio-diff · studio-import · package-management (stale flow.yaml fixtures missing compat.engine_min, and a digest-labelled install button) · flows-authoring (3) · m11b-takeover · m11c-settings-enforcement · m13-assignments · m16-external-operations · m22-workbench · m27-flow-editor · m27-platform-mcp · adr160-rework-claim · adr161-node-interrupt (the last two needed no change) · flow-package-viewer · flow-studio-artifacts · studio-local-edit (3). The product bug: IntegrationsPanel, a server component, called isManagedTokenRow from a "use client" module, so the project Integrations tab crashed on any project holding at least one API token.

    Two causes recur — check both before diagnosing anything else. waitForLoadState("networkidle") cannot settle against a Next dev server and will burn the whole test budget; wait for the specific response instead. No e2e spec calls it any more — keep it that way. Note that a save is not always a route POST: the LOCAL package editor passes a CLIENT function to <form action={...}>, so React calls it directly and it emits one PUT/DELETE per changed file with no navigation at all.

    And a UI that renders the same text twice (a header pill plus an inspector row, a page <h1> plus a section <h3>) turns a bare getByText/getByRole into a strict-mode violation, which reads like a missing element but is the opposite.

    A freshly loaded page can be in the DOM twice. Every (app) page streams behind app/(app)/loading.tsx. React 19.2 reveals a finished boundary on a throttle ($RC queues it, $RV swaps it in up to ~300 ms later), and until then the server's copy waits in a hidden <div id="S:0"> at the end of <body>. An update that reaches the still-dehydrated boundary, such as an attention tick's router.refresh(), makes React client-render the page into <main>. Until ADR-171 D7 every load got that tick (the connect-time snapshot). Now only a page that something moved under does, so the window is rarer but not gone. For that window a page-level getByTestId or CSS locator matches both copies (role locators skip the hidden one). The signature is strict mode violation: getByTestId('<page test id>') resolved to 2 elements, the second one outside <main>, often after a Received: hidden poll; a one-shot count() reads double. Measured on the Desk on 2026-09-26: the refresh's RSC request at 308 ms, both copies at 487 ms, the hidden one display: none under div#S:0. Read page content inside page.getByRole("main"), as desk.spec.ts does. Its race test holds the reveal (holdStreamedReveal) and pushes a tick once the copy is parked, so it fails if the scoping regresses. Scoping has one cost: a one-shot count() taken before the page reaches <main> now reads 0, where it used to count the parked copy, so wait for the region to be visible first. The same signature showed on push-notifications:103/218 (notifications-panel), work-table:97 (work-empty) and activity-feed:51, and those specs now read page content inside <main> too. outbound-webhooks:363's violation was different: two visible rows left by a failed first attempt, i.e. a retry that lied.

    Assume things are collapsed. Much of this UI now hides content behind a disclosure, a non-default tab, or a collapsed tree folder — the workbench Files/Diff tabs, the package viewer's raw YAML, the composition Files tab and its folders, the Studio AI dock (which a graph-node selection also force- closes). Some render hidden, some do not mount at all; the second kind fails fast, but scrollIntoViewIfNeeded() on the FIRST kind never fails at all — it retried for a full 180 s budget, which is what made studio-local-edit look slow rather than red.

    Do not assume a click focused CodeMirror. Measured on flow-package-viewer: on failing runs document.activeElement was the body, on passing ones cm-content. Keystrokes are then swallowed silently and the buffer keeps its original text, so the failure surfaces far away — at a later hidden-input assertion — looking like a broken form. Click until focus lands, and scope to the intended editor: a page can host several.

    They were deterministic, not interference. Sampled studio-diff, project-registration and m13-assignments — three different areas — and all three failed in isolation (2 workers, no cross-spec load) AND on a detached master with the identical pass/fail split. So the shared-DB-interference explanation this config's retries: 1 exists to absorb does NOT cover them; each failed both its attempts. m13-assignments failed in 9.1 s, far too fast to be host saturation.

    A retry can lie. Several of these specs mutate seeded state one way — a claim, a git commit, a token create. Once the first attempt fails partway, the retry fails somewhere else entirely and its error describes the first attempt's leftovers, not the defect. Diagnose the FIRST attempt.

    4 workers oversaturates this Mac on a wide selection. A 28-spec run at --workers=4 failed adr160-rework-claim and forked-package-loop:518 in both attempts; the SAME 28 specs at --workers=2 went 57 passed, exit 0, and finished faster (1.9 m vs 2.2 m). Both stragglers also pass alone and paired with each suspected neighbour. Halve the workers before reading anything into a wide run's failures — and note that the cap is NOT the mechanism here: playwright.config.ts sets MAISTER_MAX_CONCURRENT_RUNS: "64" for e2e.

    Read the host before believing a timeout-class failure. The 2026-09-17 measurement ran at load average 55-59 on 16 cores, against a competing vitest --project integration from another worktree and a 688 %-CPU VM. That does not explain a 9 s deterministic failure, but it does inflate the 30 s toBeVisible and 180 s click timeouts in this set — re-measure those on a quiet host before diagnosing them as product bugs.

    Not in the set, deliberately: execution-ab-content.spec.ts (4 cases) used to fail here every run. It owns a lane of its own (playwright.execution-ab.config.ts) with a dedicated supervisor and runtime roots, was not listed in AUTHED_SPEC, and so ran UNAUTHENTICATED in the default chromium project where it cannot pass. The default config now ignores execution-ab-*.spec.ts the same way it already ignored live-*.

Measured 2026-09-23 (ADR-181 branch, this Mac): two more load-sensitive integration names, both in code that branch does not touch. lib/flows/graph/__tests__/permission-resume.integration.test.ts "owner-flow-repeated-permission" failed once in a lane that started at load 157 and passed in every re-run — re-run a hit idle before reading anything into it. lib/__tests__/permission-deadline.integration.test.ts RED 13/14 (red at load ≥ 28, green at ≤ 20) was not load noise but a product bug the load exposed: the resumable mock answered the cap's cancelled permission with end_turn, which under load beat the SIGTERM, so the prompt settled succeeded and the agent grant took kind: "result" — marking the operator's stored answer delivered although no session.input ever carried it. Master's ADR-180 correction (04a3a39a) fixed it: a checkpointed permission without confirmed input resumes as continue whatever the prompt's outcome — 12/12 at load 75-278 after the fix, with the succeeded-prompt race firing in 7 of 12 cases. The tell was a stable red rate with one wrong VALUE and no timeout: a load flake does not pick an answer. Ask whether the "by-rule" outcome tells the operator the truth before blaming the fixture. The lane totals were 511 files / 4544 tests.

Budget ~25 min for the integration lane and do not mistake it for a hang. It is gated by two very slow files — lib/flows/graph/__tests__/prompt-owners.integration.test.ts (~23 min, 52 tests) and lib/agents/__tests__/prompt-owners.integration.test.ts (~15 min, 50 tests). While they run, the execution-host resolver logs sequence 0 / disposition duplicate every 2 s, several hundred times per run. That is the normal shape of these suites' event replay, NOT a wedged stream.

Any test that reaches the supervisor transport must NAME a host (M51 T8.6, 2026-09-11). lib/supervisor-client.ts refuses an unnamed host under a test runner, because the default used to fall through to the dev port and drive whatever supervisor was listening on the developer's machine — which is why a set of suites appeared to flip between green and red depending on whether pnpm --filter @maister/supervisor dev happened to be up. Install the fake seam instead: fakeExecutionHosts(db) in beforeAll for service paths that claim an assignment, fakeGraphHosts(db, runId) per run for graph paths, and pass executionHosts into runFlow. evidence-readiness-all-blocking-kinds, hitl-hook-trip and lib/flows/__tests__/runner.integration.test.ts were the last three offenders (8 failures) and are fixed; a test that wants a SPAWN failure should script the fake's turn to throw rather than omit the host, since an unresolvable host now fails at ADR-166 assignment — before the runner can record any state.

The separate takeover-resume full-suite flake was an ENOTEMPTY on a git worktree temp root, not a seam problem: a git child may still be writing pack objects when the case returns. Both of that spec's rm calls now carry maxRetries/retryDelay, the same remedy the sync-resolver temp-repo cleanup uses. Reach for that before calling a cleanup failure flaky.

Measured 2026-09-21 (ADR-176 branch, --workers=2, both runs on the same host back to back). The three durable workers DO boot in this lane — the config runs next dev, which runs instrumentation.ts — and a direct next dev probe confirms all three start with all five owner kinds. No e2e failure is attributable to them. The two sets:

  • branch (4 failed, 4 flaky, 185 passed): desk.spec.ts:205, platform-agents-page.spec.ts:26, review-diff-scopes.spec.ts:99, studio-ai-assistant.spec.ts:69.
  • master (5 failed, 187 passed): desk.spec.ts:205, forked-package-loop.spec.ts:518, platform-agents-page.spec.ts:26, review-diff-scopes.spec.ts:43, studio-ai-assistant.spec.ts:69.

Three fail in both. forked-package-loop:518 is master-only here. The one branch-only entry is review-diff-scopes:99 — but that FILE fails in both trees with the failing case trading places (:43 on master, :99 on the branch, and :43 flaky-then-passing on the branch), and the whole file passes 3/3 in isolation on the branch across three consecutive runs. It is load-sensitive, not worker-regressed.

Measured 2026-09-22 (ADR-179 branch REBASED onto master c4216cd5, --workers=2, quiet host, ports 3100/7788 freed first). 199 tests: 5 failed, 4 flaky, 188 passed (7.8m). The suite grew from 193 because master added specs.

  • failed: desk.spec.ts:205, platform-agents-page.spec.ts:26, review-diff-scopes.spec.ts:43, scratch-detail.spec.ts:50, studio-ai-assistant.spec.ts:69.
  • flaky: activity-feed.spec.ts:51, push-notifications.spec.ts:103, push-notifications.spec.ts:218, work-table.spec.ts:97.

Nothing here is attributable to the MCP value model. Four of the five failures are in the sets above (review-diff-scopes sits on :43, the documented master line, and forked-package-loop:518 no longer fails). The one name in neither set, scratch-detail.spec.ts:50, was settled by MEASUREMENT rather than by reading the diff: it reproduces identically on plain master (10.8s fail → 1.0s retry pass, two runs on each tree), and every file implementing or exercising the suggestion list is byte-identical between the two trees. Run the spec on master before attributing a name like this to a branch — after a rebase, "a NEW name" no longer implies "this branch's", because master moved too.

Measured 2026-09-23 (ADR-181 branch; its merge base c36ff5b1 reproduced in a detached worktree on this Mac). Three names had joined the set on master itself. flow-target-delegation.spec.ts:36 was a deterministic race, not load: its two-node cli flow children reached Review before the coordinator's turn ended, so the coordinator — correctly — completed without parking and the wake the spec proves never ran. The children now wait at their first node for a file the spec creates after it sees the park (e2e/_seed/delegated-release.ts). orchestrator-loop.spec.ts:56 and m11b-takeover.spec.ts:67 were budget-bound: ~19 s and ~22 s on a quiet host against the 30 s default, out of budget at load 40-72 with --workers=2; both now carry 120 s (m11b also waits 15 s for the claim's refresh and 60 s for the post-return resume). push-notifications.spec.ts:103 was intermittent here under either config (Notification.permission === "denied" at mount). Measured 2026-09-26: this headless Chromium reads it as "denied" whatever the context grants, context-wide or origin-scoped. The panel hides the other-browsers count in that state, so "1 other browser" was only ever read off the server's markup before the panel mounted. The test now pins the permission "Allow" produces. What found the race was the dev server's own log — temporarily set webServer.stdout: "pipe" in playwright.config.ts ("orchestrator turn ended with no pending children"). That log also showed the observatory fixture's task-less Pending flow run being promoted whenever a slot freed (promoteNextPending runFlow dispatch failed — task not found); the fixture seeds no queued run any more.

pnpm lint on master c4216cd5 is RED: 3 errors, all react/no-children-prop in components/observatory/__tests__/ — 2 in observatory-filter-bar.test.ts, 1 in overview-table.test.ts, landed by the observatory merge. The historical baseline (0 errors / 14 warnings) describes the tree BEFORE that merge. Lint the changed files alone to see your own contribution: this branch's 92 changed web files give 0 errors.

webServer.stdout defaults to "ignore", so this lane DISCARDS the dev server's structured logs. Only its stderr is piped, which is why the [WebServer] lines you see are deprecation warnings and React noise while every pino INFO line — including prompt-owner-worker-started — is invisible. Absence of a log line here is not evidence the code did not run; verify with a direct next dev instead. Not changed here (it is an e2e-config change outside this branch's request), but it costs real diagnosis time.

Known-flaky: e2e/recursive-harness.spec.ts fails roughly one run in three under the full parallel suite and passes in isolation. The test supervisor delegates SYNCHRONOUSLY inside its sendPrompt handler, so the supervisor's response waits on Next while Next's runner waits on the supervisor's response. When the dev server is saturated that round-trip is starved long enough for the coordinator's node to give up, its run-bound facade token is revoked, and the next delegation returns 401 UNAUTHENTICATED "revoked" — leaving a short fan-out (one grandchild instead of two). orchestrator-loop shares the design but makes 2 delegations to this spec's 6, so it is far less exposed. Restructuring that handler is a backlog item; do NOT weaken the spec's fan-out assertion to hide this.

Current code structure

web/
├── app/                  # Next.js App Router
│   ├── layout.tsx        # Root layout: Providers + Navbar + container
│   ├── page.tsx          # Home (template stub — to replace with run list)
│   ├── providers.tsx     # local theme provider wrapper
│   ├── error.tsx         # Root error boundary
│   ├── about/            # template stub
│   ├── blog/             # template stub
│   ├── docs/             # template stub
│   └── pricing/          # template stub
├── components/
│   ├── navbar.tsx        # HeroUI Navbar wired to siteConfig.navItems
│   ├── theme-switch.tsx  # local theme toggle
│   ├── icons.tsx         # inline SVG icons (IconSvgProps type)
│   ├── primitives.ts     # title()/subtitle() via tailwind-variants
│   └── counter.tsx       # HeroUI <Button> demo (delete when no longer used)
├── config/
│   ├── site.ts           # navItems, navMenuItems, external links
│   └── fonts.ts          # system font CSS variable class names
├── styles/
│   └── globals.css       # @import "tailwindcss"; @import "@heroui/styles";
│                         # declares @custom-variant dark (&:is(.dark *))
├── types/
│   └── index.ts          # IconSvgProps
├── public/
├── next.config.mjs       # empty config
├── postcss.config.mjs    # @tailwindcss/postcss plugin only
├── eslint.config.mjs     # flat config (see Conventions below)
├── tsconfig.json         # strict; paths `@/*` → `./*`; jsx: react-jsx
└── pnpm-workspace.yaml   # only declares allowBuilds for native deps
                          # (NOT a real monorepo workspace)

What to build here (mapped from ../docs/)

Pages — replace template stubs:

  • app/page.tsx → Portfolio home (superset.sh-style): grid of active workspaces across all projects. Card = project · branch · status · last activity · quick actions. Filters by project + status.
  • app/projects/page.tsx → Projects list: registered projects + "Add project" button. Each row links to its board.
  • app/projects/new/page.tsx (or modal) → Add project: paste path to a directory containing maister.yaml; server validates & stores.
  • app/projects/[slug]/page.tsx → Project board: 2 columns Backlog | In Flight. In Flight bucket: Running | NeedsInput | NeedsInputIdle | Review | Crashed. A task card in Backlog shows a Launch button — click runs precondition checks and creates a new Run (no drag-and-drop in POC). HITL form renders inline on the In-Flight card when state is NeedsInput*. Dedicated Inbox block beside the board lists pending HITL requests across all flows of the project. Done/Abandoned in a filter tab beside the board.
  • app/projects/[slug]/tasks/new/page.tsx (or modal) → Task creation: title + prompt + Flow dropdown (from maister.yaml flows[]). Runner selection happens at launch time from the platform ACP runner catalog.
  • app/runs/[id]/page.tsx → Run detail: status, live logs, HITL form panel, diff view, action buttons (mark ready, merge, abandon, recover). Worktree context shown (project + branch + worktree path + executor). Page sends periodic POST /api/runs/[id]/activity while visible to extend keepalive_until and prevent premature checkpoint.

Route Handlers under app/api/:

  • POST /api/projects (validate maister.yaml v2 + install referenced Flow plugins from git tags, persist row)
  • DELETE /api/projects/[slug] (soft-archive — sets archived_at)
  • POST /api/projects/[slug]/tasks (create task → status Backlog)
  • POST /api/runs (precondition checks, ACP runner resolution, git worktree add, supervisor POST /sessions — body carries taskId and optional runnerId; projectId derived from task)
  • GET /api/runs/[id]/stream (SSE; lastEventId reconnect; tails the per-step log file populated by supervisor-client)
  • POST /api/runs/[id]/hitl-response (atomic write input-<step-id>.json under the project subtree → if worker live, supervisor delivers ACP message; if checkpointed, supervisor respawns + resumes via session/resume)
  • POST /api/runs/[id]/activity (extend keepalive_until by 30 min while user is actively on the run page)
  • GET /api/runs/[id]/diff (raw diff + per-file +/− summary; rendered split/inline via @git-diff-view/react, ADR-066)
  • GET/POST /api/runs/[id]/review-comments (ADR-072 — list line-anchored review threads with computed inline|outdated placement; create a root comment or reply at an open review gate)
  • PATCH/DELETE /api/runs/[id]/review-comments/[commentId] (ADR-072 — author-only body edit or root-only resolve/unresolve; author-only delete, root cascades replies)
  • POST /api/runs/[id]/merge (git merge --no-ff; conflict → abort, run stays Review)
  • POST /api/runs/[id]/abandon (supervisor DELETE /sessions/:id if alive; mark worktree stale; task → Abandoned)
  • POST /api/runs/[id]/recover (Crashed → respawn + session/resume on acp_session_id if present; otherwise force-discard worktree)
  • GET /api/runs/[id]/artifacts (M12 — list the typed-artifact evidence index; optional node/kind/validity filters)
  • GET /api/runs/[id]/artifacts/[artifactId]/payload (M12 — raw payload per locator; text/plain or application/json, execution-object bytes as application/octet-stream and gated by readRepoFiles like the direct content route; always a sandboxed no-store attachment — AB-12; path-confined to the run dir)
  • GET /api/cron/gc (Abandoned/Done/Failed worktrees + checkpointed sessions >7d, all projects)

Primary nav is the role-driven left rail (components/chrome/left-rail.tsx): Projects (/), Inbox (/inbox), Flows (/flows), plus an admin-only block (Agents /agents (M34), MCPs /mcps, Users, Scheduler, Settings). Supervisor status lives once in the footer status bar. The config/site.ts navItems are template leftovers, not the live nav.

Server-side modules (add as needed, names suggested — not yet present):

  • lib/errors.ts — MaisterError discriminated union (see root §3, expanded taxonomy includes EXECUTOR_UNAVAILABLE | FLOW_INSTALL | ACP_PROTOCOL | CHECKPOINT).
  • lib/atomic.ts — atomicWriteJson (tmp + rename).
  • lib/worktree.ts — git worktree add|remove|list wrapper, project-scoped paths.
  • lib/config.ts — maister.yaml v2 loader (project + flows[] with version pins and optional project runner refs), schemaVersion check, zod-validated, slug derivation. Also loads Flow plugin manifests (flow.yaml) with portable runner_profiles.
  • lib/flows.ts — Flow plugin loader: git clone --branch <tag> into ~/.maister/flows/<id>@<tag>/ system cache, symlink into .maister/<slug>/flows/<id>/, manifest validation, version-pin enforcement.
  • lib/acp-runners/* — platform runner catalog, readiness, usage references, Flow runner remaps, and launch-time runner resolution (launch override → Flow step target → project Flow default → platform Flow default → project default → platform default).
  • lib/execution-host/ — (Implemented — ADR-166) the supervisor boundary: registrar/resolver, assignment mint, command ledger + deliverer, BoundClient per assignment / HostAdminClient.
  • lib/supervisor-client.ts — HTTP+SSE local-direct transport to ../supervisor/ (importable only from lib/execution-host/**): POST /sessions, DELETE /sessions/:id, GET /sessions/:id/stream, POST /sessions/:id/input (deliver HITL response when worker is live). Reconnect with lastEventId.
  • lib/projects.ts — registry CRUD, slug derivation, slug + repo_path uniqueness enforcement, recursive MAISTER_PROJECTS_DIR auto-discovery, Flow plugin install on register.
  • lib/scheduler.ts — global concurrency cap (MAISTER_MAX_CONCURRENT_RUNS), Pending queue, auto-promote on slot free.
  • lib/db/ — Drizzle schema (projects, platform_acp_runners, flows, flow_revisions, tasks, runs, workspaces, hitl_requests) + client.
  • lib/reconcile.ts — startup hook: per-project runs vs git worktree list vs supervisor's live session set; orphan Running with no live ACP session and no checkpoint → Crashed. NeedsInputIdle with valid checkpoint stays valid.

There is NO lib/runner.ts — agent subprocess lifecycle lives in ../supervisor/, not Next.js. The web tier talks to supervisor via lib/execution-host/ only (ADR-166, Implemented); lib/supervisor-client.ts is that module's transport.

Drizzle schema sketch (server-only, lib/db/schema.ts):

// projects
{ id, slug (unique), name, repo_path (unique), main_branch, branch_prefix,
  maister_yaml_path, default_runner_id?, created_at, archived_at? }

// platform_acp_runners                   // platform-scoped launch catalog
{ id, adapter: 'claude' | 'codex', capability_agent: 'claude' | 'codex',
  model, provider (jsonb), permission_policy,
  readiness_status, enabled, created_at, updated_at }

// flows                                  // installed Flow plugins per project
{ id, project_id, flow_id, source_url, version_tag,                 // tag-pinned
  install_path,                            // resolved symlink target
  manifest (jsonb),                        // parsed flow.yaml (steps, etc.)
  enabled_revision_id?,
  installed_at }

// tasks
{ id, project_id, title, prompt, flow_id, status:
  'Backlog' | 'InFlight' | 'Done' | 'Abandoned',
  latest_run_id?,                          // FK to runs; null until first Launch
  attempt_number,                          // monotonic per task, starts at 1
                                           // UNIQUE (task_id, attempt_number) on runs
  created_at, updated_at }

// runs                                    // task : runs is 1 : N (retry / ralph-loop)
{ id, project_id, task_id?, flow_id?, workspace_id, runner_id?,
  runner_resolution_tier?, capability_agent?, runner_snapshot?,
  acp_session_id?,                         // resume handle for session/resume
  flow_version_tag,                        // snapshot at launch time
  status:
    'Pending' | 'Running' | 'NeedsInput' | 'NeedsInputIdle' | 'Review' |
    'Done' | 'Abandoned' | 'Crashed' | 'Failed',
  // attempt counter lives on tasks.attempt_number in M5; (Designed M8)
  // moves to runs.attempt_number with UNIQUE (task_id, attempt_number).
  current_step_id?,
  checkpoint_at?,                          // when graceful checkpoint happened
  keepalive_until?,                        // 30 min sliding window in NeedsInput
  started_at, finished_at? }

// workspaces
{ id, project_id, run_id, branch, worktree_path, status, created_at,
  removed_at? }

// hitl_requests
{ id, run_id, step_id, kind: 'permission' | 'structured_form' | 'human_review',
  question, context, response_schema (jsonb)?,        // null for binary permission
  response (jsonb)?, on_reject_goto?,                  // for human-review send-back
  requested_at, responded_at?, expires_at }

Task status is the board axis (Backlog | InFlight | Done | Abandoned). Run status is the execution axis (richer state machine).

Task lifecycle (1:N task→run):

  • New task → Backlog.
  • Launch click → precondition checks (project active, clean repo, branch free, worktree path free, global cap not hit, selected executor registered) → supervisor POST /sessions → task → InFlight. Task stays InFlight while the latest run is in any of Pending/Running/NeedsInput/ NeedsInputIdle/Review/Crashed.
  • Latest run merged → task → Done (terminal).
  • Latest run Failed | Abandoned → task auto-returns to Backlog; Launch button reappears on the card; next click spawns attempt N+1 against the same task with a fresh worktree. New attempt_number = max + 1.
  • "Discard task" (single explicit user action on the card) → task → Abandoned (terminal). No automatic transition to Abandoned — it always takes an explicit user click. (Run-level abandon ≠ task-level abandon.)

Run lifecycle (ACP keep-alive + checkpoint+resume):

  • Pending → scheduler promotes within concurrency cap → Running.
  • ACP session emits session/request_permission or agent writes needs-input.json → Running → NeedsInput. keepalive_until set to now + 30 min.
  • Each web-console activity (run page open/focus/keystroke in form) → bump keepalive_until by +30 min.
  • now > keepalive_until while still NeedsInput → supervisor checkpoints the agent (graceful exit, acp_session_id retained), sets checkpoint_at = now → NeedsInput → NeedsInputIdle.
  • User submits response → lib/atomic.ts writes input-<step-id>.json →
    • If Running/NeedsInput (worker live) → supervisor delivers ACP message → resume in-process.
    • If NeedsInputIdle → supervisor spawns a fresh process and restores context via session/resume on acp_session_id → reads the input artifact → Running.
  • 24h in NeedsInputIdle without response → Abandoned.
  • Crash mid-Running (heartbeat dead, no checkpoint) → Crashed. UI surfaces "Recover or discard" — Recover attempts session/resume with the last acp_session_id if present, otherwise discard worktree.

This shape supports ralph-loop style retry without recreating tasks, AND keeps long human reviews from being penalized for thinking time.

Conventions

TypeScript

  • strict: true is on. Honor it. No any unless flagged with // FIXME(any):.
  • Use the @/... alias for imports rooted at web/ — never deep relative ../../...
  • target: "es5" is the template default; do not narrow further. Modern syntax compiles fine.

React / Next.js

  • Default to Server Components. Add "use client" only when a component uses state, effects, browser APIs, or HeroUI components that require client context (most do — <Button>, themed inputs, modals). Match the template: providers.tsx, counter.tsx, theme-switch.tsx are explicit "use client".
  • Route handlers and server actions live in app/. Keep secret-touching logic server-side; never ship API keys to client.
  • Default theme is dark (<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}> in app/layout.tsx). Use @/lib/theme useTheme() to toggle.

Styling

  • Tailwind 4 utility classes. Use HeroUI Button, Card, Modal, Input, Navbar etc. instead of hand-rolling. Exception: components/workbench/lifecycle-actions.tsx owns a custom accessible lifecycle dialog matched to the compact workbench surface; preserve that convention unless the whole surface is redesigned.
  • For variant-based class composition use tailwind-variants (tv(...)), mirroring components/primitives.ts.
  • Dark variant is declared in styles/globals.css as @custom-variant dark (&:is(.dark *)). Use dark: prefix as usual.
  • Class merging: clsx is fine for ad-hoc combos (already in the layout); for variant APIs use tailwind-variants.

Linting (auto-enforced by pnpm lint)

  • no-console: warn. Use a logger boundary, not console.log in committed code.
  • react/jsx-sort-props: callbacks last, shorthand first, reserved first. Auto-fixable.
  • import/order: type → builtin → object → external → internal → parent → sibling → index, with blank lines between. Auto-fixable.
  • padding-line-between-statements: blank line before return; blank line after const/let/var blocks. Auto-fixable.
  • unused-imports/no-unused-imports: warn. Strip dead imports.
  • react/self-closing-comp: warn.

Files & naming

  • React components: kebab-case.tsx file, named exports preferred (template uses both — pick named for new components).
  • Route handlers: app/api/<segment>/route.ts, exporting GET/POST etc.
  • Server-only modules importing node:*/secrets: keep in lib/ and never import from a Client Component.

Data-management page patterns (list / table / admin pages)

Build these pages to this bar the first time — the explicit goal is to not rewrite page functionality later. Reference impl: components/admin/users-table.tsx + components/admin/user-edit-modal.tsx + app/(app)/admin/users/page.tsx.

The admin /settings platform ACP runner catalog is a second instance of this pattern: components/settings/acp-runners-panel.tsx (view-only table) + components/settings/acp-runner-modal.tsx (one create | edit modal, also owns delete) + the DELETE /api/admin/acp-runners/[runnerId] usage-guard. Runner CRUD is admin-only and the page is reachable from the admin section of left-rail.tsx. Domain contract: docs/system-analytics/acp-runners.md

  • ADR-065. (Filters are intentionally omitted — small N.)

The create | edit modal's key/value rows live in components/settings/key-value-rows.tsx (ADR-179), extracted from the runner modal and shared with the platform MCP modal, the project MCP modal, the MCP binding-overlay dialog and the Studio MCP template editor. Its labels are PROPS rather than useTranslations calls, so each surface keeps its own i18n namespace; it exports rowsFromRecord / recordFromRows / duplicateKeyIds. Reach for it before hand-rolling another Record<string, string> editor.

  • Tables are view-only. No inline editing, row dropdowns, or row-level mutate buttons. Rows display data only.
  • Edit lives in a popup or a dedicated edit page, never inline. Popup = modal following user-edit-modal.tsx; heavier edits get their own route.
  • Filter on the main fields of the list (e.g. users: name search, role, status, project access) — not just one field.
  • Apply changes through ONE aggregating endpoint (partial body in a single server transaction), never a client-side fan-out of per-field calls + manual compensation. See lib/users.ts updateAdminUser + PATCH /api/admin/users/[userId].
  • Full-width list/table pages: drop mx-auto max-w-[...]; rely on the main px gutter for air, put a min-w-[...] on the table inside overflow-x-auto, and use responsive md: breakpoints (mobile is a target). Forms stay narrow (520–760px) — do not widen forms.
  • URL-synchronized, deep-linkable state. Filters, tabs, pagination, and expanded/selected state belong in URL query params (anchors / searchParams, or nuqs) so views are shareable, survive refresh, and respect back/forward — not useState-only.
  • Accessible interactions, built right: modals get focus-trap + initial focus + focus-restore via refs, Escape-to-close, body scroll lock, aria-labelledby; form controls get <label>/aria-label; async updates/errors get role="alert"/aria-live; locale dates use Intl + suppressHydrationWarning. Use refs for focus/measurement, not to mask re-renders.
  • Role-driven nav + access: admin-only areas live in the left sidebar (components/chrome/left-rail.tsx), gated by role; the route still enforces requireGlobalRole. The hidden nav item is convenience, never the authorization boundary.
  • Cursor: enabled <button>/[role=button] get cursor: pointer app-wide via the @layer base rule in styles/globals.css — don't add per-button cursor-pointer.

UI affordance conventions

A visual affordance is read faster than a word — favour glyphs.

  • Prefer icon buttons (or icon + label) over text-only buttons for actions. A recognizable glyph makes the action scannable; reach for an icon-only control where space is tight (table/row actions, toolbars) and icon + label where the action is rare or ambiguous. Icon-only controls MUST carry an accessible name (aria-label or title); icon + label keeps the visible text as the name. Use @heroicons/react (already the rail/nav icon source) — do not hand-roll SVGs for standard actions.
  • Destructive actions (delete / remove) use a distinct icon and tone — e.g. a trash glyph with a danger/rose colour — never the same neutral treatment as Edit. A row's action cluster reads left-to-right primary → secondary → destructive.
  • Signal success with a green check glyph, not the word "Succeeded". Outcomes are status, and a green ✓ (or red ✗ / amber warning for failure) is read at a glance; reserve a word only when the glyph alone is genuinely ambiguous, and even then keep it short. This applies to inline result indicators, toasts, and status cells (the readiness dot + the Enabled ✓/— cell already follow this).
  • Feedback and confirmation (UI completion batch). Use the single shared feedback provider for completed mutations; user-facing action errors resolve to EN/RU copy and never show a raw code, HTTP status, or server message. Use the shared portaled confirmation for destructive actions. While it is busy, Escape, backdrop, cancel, and repeat confirmation are disabled; focus returns to its trigger after closure. Inline validation and durable remediation stay in their local surfaces.
  • Liveness and route resilience (UI completion batch). Async route segments use the shared token skeleton with aria-busy; EventSource consumers show a text-plus-color accessible liveness state and reconnect action without changing persisted run state. A route boundary may expose a known error code only as a localized, explicitly labeled diagnostic field.
  • HeroUI buttons and the app-wide cursor: pointer base rule (above) still apply to every affordance, icon or not.

HeroUI integration notes

  • @heroui/styles is imported once in styles/globals.css. Don't re-import it in components.
  • Providers chain in app/providers.tsx currently wraps the local theme provider only. When HeroUI ships a top-level provider for v3 features (toast, modal stack), add it inside <ThemeProvider>.
  • For new themes/colors prefer extending CSS variables in globals.css over forking HeroUI tokens.

Template leftovers to clean (when you next touch them)

  • LICENSE inside web/ duplicates the root MIT — keep root only, drop this one when convenient.
  • README.md: HeroUI template README; replace when we have something to say.
  • Stub routes (app/about, app/blog, app/docs, app/pricing) and components/counter.tsx: delete as MAIster routes land.
  • app/page.tsx and config/site.ts still reference HeroUI demo content (navItems: Docs/Pricing/Blog/About). Replace navItems with MAIster nav (Portfolio, Projects, Settings) when wiring real pages.

Flag these in PRs but do NOT mass-delete in unrelated commits (surgical-changes rule from root CLAUDE.md).

House rules (carried over from root, repeated for the slice)

  • No chokidar / fs.watch / polling for state transitions. The live path is supervisor's ACP notifications bridged through SSE; the recovery path is supervisor heartbeat + reconcile-on-startup.
  • All writes to .maister/<project-slug>/runs/<run-id>/ are atomic (tmp + rename via atomicWriteJson). Flow / agent may read them mid-write otherwise.
  • Throw MaisterError with a code for known domain failures; UI branches on code. New codes: EXECUTOR_UNAVAILABLE, FLOW_INSTALL, ACP_PROTOCOL, CHECKPOINT.
  • Every externally visible ACP event must be committed to the supervisor outbox before publication and projected through canonical Postgres state. Raw logs stay host-private and are exposed only as opaque runtime objects.
  • Agent processes live in ../supervisor/, NOT in Next.js. Web tier talks to them via lib/execution-host/ only (ADR-166, Implemented); lib/supervisor-client.ts is its lint-fenced transport.
  • Anything in the root CLAUDE.md "Out of POC scope" list does not get implemented here either.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.