SLOPSHOPPER

moarch-dev

Release and template checks for working on moarch: version sync, the bump-again rule, stack parity and the agent guide.

newpanebandcommandstatusprocess
★ 1v0.1.0MITupdated 2026-10-08SuperMoooo/moarch/.claude/skills/moarch-dev
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · moarch-dev
│ ┃ moarch dev ✕ › fix the failing auth test and add an audit log call │ ┃ Not a moarch checkout: pubspec.yaml does not │ ┃ name moarch. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /moarch-dev │ ⎿ moarch-dev: moarch dev pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · moarch dev
Not a moarch checkout: pubspec.yaml does not name moarch.
README

moarch

A simple Dart/Flutter CLI for scaffolding Clean Architecture-style apps.

pub version license: MIT

Built first as a helper for me and the people I work with — it encodes the conventions our projects share. Anyone is welcome to it: if the conventions match yours, use it as-is; if they almost do, clone it and make them yours — every template is a plain Dart string meant to be edited.

Install

dart pub global activate moarch

If moarch is not found, make sure your Pub bin folder is on your PATH. It needs Dart 3.9 or later (Flutter 3.35+).

Quick start

flutter create my_app
cd my_app
moarch init
fvm use          # creates .fvm/flutter_sdk — see below, do this before opening the editor
fvm flutter pub get
fvm dart run build_runner build --delete-conflicting-outputs  # generates app_env.g.dart
moarch create feature auth
moarch create model auth login_response

init writes a .fvmrc pin and a .vscode/settings.json that points dart.flutterSdkPath at .fvm/flutter_sdk — the symlink fvm use creates. .fvm/ is gitignored, so every fresh clone has to run fvm use once. Skip it and nothing errors: the Dart extension quietly falls back to the first Flutter on your PATH, so debug, hot reload and the analyzer all run the SDK the pin exists to avoid. moarch doctor flags it if you forget.

The generated .fvmrc says "flutter": "stable" so a new project starts on the current stable. That is an alias, not a pin — fvm install on CI or a teammate's machine resolves it to whatever stable is that day, which can be a different SDK than your cache holds. Once the project ships, pin it for real:

fvm use 3.41.4   # rewrites .fvmrc to that exact version

fvm use also rewrites .vscode/settings.json to a versioned path and strips its comments. Put ".fvm/flutter_sdk" back — it follows .fvmrc, so later SDK switches never touch the editor config. moarch doctor --fix does exactly that.

Commands

moarch init          # interactive scaffold
moarch init --all    # generate the default structure without prompts
moarch init --state bloc   # pick the stack without the checklist (riverpod | bloc)
moarch create feature <featureName>   # all layers, registered in the locator, with its route
moarch create model <featureName> <modelName> # generate the model
moarch create model <featureName> <modelName> --from-json sample.json # infer the fields from a JSON payload
moarch create model --empty <featureName> <modelName> # Inject a .empty() factory into an existing model.
moarch create flavors # dev/staging/prod via flutter_flavorizr — one main.dart, untouched
moarch create empty-factories # generate .empty() in all models
moarch create bloc <featureName> <blocName> # add a state+event+bloc trio to an existing feature
moarch create widget <name>        # add a UI-kit widget on demand (e.g. switch, otp, list-tile)
moarch create widget all           # generate the whole UI kit + the preview screen
moarch create widget --list        # list every available widget
moarch create theme --dark         # add the dark palette, AppTheme.dark and the saved theme-mode switch
moarch create theme --no-dark      # ...and drop back to the single brand theme
moarch create tests [feature]      # unit tests for every notifier/bloc, integration tests for every GET endpoint
moarch create scope <feature> <name> [--blocs A,B] [--parent XScope]  # carry a screen's blocs to what it opens (bloc)

moarch update        # refresh every generated file against the current templates
moarch update <name> # ...or just one (e.g. validation, extensions, theme)
moarch update <group># ...or one group (widgets, core, security, config, docs...)
moarch update --list # list every name and group update accepts
moarch doctor        # check the project for common scaffolding issues
moarch doctor --fix  # ...and apply the ones that don't need a decision

What it generates

  • lib/main.dart, core/, config/, shared/, and features/
  • README.md — the project's own guide, written for someone who has never worked on a Clean Architecture Flutter app: the first run through FVM, a layer-by-layer walkthrough of the generated code, a state-management section for whichever stack you picked, flavors, the build commands, and the CI secrets (pointing at docs/ for the step-by-step). It replaces the stock flutter create README and only that one — a README you wrote is left alone, and moarch update readme --diff shows what a refresh would change
  • Riverpod or flutter_bloc (see below) + optional GoRouter setup
  • Envied-based .env support
  • secure storage, logger, helpers, and a full shared UI kit / design system (see below)
  • optional services such as notifications (local or Firebase push), URL launcher, media, debounce — each with what it needs declared in AndroidManifest.xml and Info.plist (camera and photo permissions for media, the exact-alarm permission and receivers for scheduled notifications, <queries> for the URL launcher, INTERNET for Dio's release build), since a missing declaration fails at runtime rather than at build time
  • core/constants/api_constants.dart — every endpoint path the app calls, never a string at the call site. moarch create feature declares each new feature's above a // moarch:endpoints anchor, the way it registers the data layer and the route
  • an optional maintenance gate — a backend flag that empties the app (see below)
  • an optional offline screen over the app, and a hook that runs when the connection comes back — for a sync (see below)
  • optional deep links: https links open the app on the matching route, Android App Links and iOS Universal Links (see below)
  • optional localization: flutter_localizations (lib/l10n/ + .arb files) or easy_localization (assets/translations/ JSON files) — pick one, the checklist keeps them mutually exclusive
  • a backend: Dio against a REST API, Firebase (Firestore / Auth), or both (see below)
  • AGENTS.md + CLAUDE.md — the project's rules for coding agents (Codex, Cursor, Copilot, Gemini CLI, Claude Code): the layout, no entity layer, the state stack's patterns, the UI kit and tokens, and the commands that prove a change is done. CLAUDE.md is only @AGENTS.md, so there is one set of instructions. Both are catalog entries, so moarch update agents claude-md keeps them in step with the templates; a project from before 9.0.0 gets them from moarch doctor --fix
  • .agents/skills/ — step-by-step skills for the tasks agents get asked most, written for the project's stack and options: moarch-add-feature, moarch-plan-feature (an interview that settles a feature's data, screens, actions, route and platform needs before any code, one round of questions at a time, each with a recommended answer), moarch-add-endpoint, moarch-add-action, moarch-add-model, moarch-build-screen, moarch-preview-widget (@Preview functions for Flutter's widget previewer, in the app's theme through a project AppPreview annotation), moarch-add-env-key, moarch-write-tests, moarch-fix-bug (reproduce first: a failing test at the layer that owns the symptom, then the fix), moarch-update-scaffold and moarch-review (a checklist review of a diff against the rules). Codex, Cursor, Copilot and Gemini CLI read .agents/skills/; Claude Code reads only .claude/skills/, so each skill has a pointer there, the same way CLAUDE.md imports AGENTS.md. Alongside them: .claude/settings.json allows the format / analyze / test / build_runner commands without a prompt and denies reading .env and editing *.g.dart, *.freezed.dart and .moarch.yaml, and .gemini/settings.json points Gemini CLI at AGENTS.md. .mcp.json (Claude Code) and .gemini/settings.json both start the Dart MCP server through FVM (fvm dart mcp-server), so an agent can launch the app, hot reload it and read its runtime errors and widget tree; AGENTS.md and the build-screen and fix-bug skills tell it to when the server is there. For Claude Code there is also a mod in .claude/skills/moarch-mod/: function hooks that refuse edits to build_runner's output and to the // moarch: anchors, show build_runner pending on the status line after a model or env change, open a project pane with /moarch (stack, features, files changed since moarch wrote them, session cost), and add a haiku moarch-mod:runner subagent for long-output checks. Claude Code loads it once the workspace is trusted. All of it is the ai group: moarch update ai refreshes it, a project from before 9.1.0 gets it from moarch doctor --fix, and so does a project that has the skills but not the ones added since, or not the mod, or no .mcp.json. Generic skills can be installed beside these — Flutter ones (like flutter/skills' flutter-fix-layout-issues) and process ones (like mattpocock/skills' grill-me); AGENTS.md says that where one disagrees with the project's rules, the rules win
  • .vscode/ — settings.json pointing the Dart extension at the fvm SDK .fvmrc pins, and launch.json with debug/profile/release entries plus a flavored pair for dev, staging and prod (ready for when the native side declares them)
  • android/app/proguard-rules.pro — the R8 keep rules for the Flutter engine, Firebase, OkHttp and coroutines. Inert until you enable minification for the release build type, so it costs the debug build nothing; the gradle block that turns it on is in docs/SECURITY_BEFORE_DEPLOYMENT.md

Riverpod or flutter_bloc

The first question moarch init asks. It decides the shape of every state-bearing file, and nothing else about the architecture moves: the same layers, the same file names, the same AppException reaching the same AppAsyncView.

Riverpodflutter_bloc
state holderAsyncNotifier<OrdersState>Bloc<OrdersEvent, OrdersState>
lives inpresentation/notifiers/orders_notifier.dartpresentation/blocs/orders_bloc.dart (+ orders_event.dart)
the stateone class inside AsyncValue, in presentation/states/one class with an AppStatus field, in presentation/blocs/ beside the bloc
you callref.read(p.notifier).refresh()context.read<OrdersBloc>().add(const OrdersStarted())
the screenpresentation/views/orders_view.dartpresentation/pages/orders_page.dart provides the bloc, presentation/views/orders_view.dart draws it
the view usesAppAsyncView + ref.listenActionBlocConsumer + AppStatusView
dependenciesget_it, in config/di/injector.dartget_it, in config/di/injector.dart
extra packagesget_itflutter_bloc, bloc, equatable, get_it, bloc_lint

Every command reads the choice back off pubspec.yaml, so there is no flag to remember: moarch create feature orders in a bloc project generates a bloc.

The state a screen is in

The two stacks answer this differently on purpose, because they already disagree about it.

Riverpod has AsyncValue, which is the four states, so the generated OrdersState is only the data plus the one-shot action fields — unchanged from previous versions:

class OrdersState implements ActionState<OrdersState> {
  final bool isLoadingAction;
  final String? error;      // cleared by every copyWith, so it toasts once
  final String? success;
}

Bloc gets the same one class, with the phase as a field:

enum AppStatus { initial, loading, success, failure }   // core/utils/

class OrdersState extends Equatable {
  final AppStatus status;
  final String? errorMessage;    // dropped by every copyWith, so it toasts once
  final String? successMessage;  // ← and you add the screen's own fields
}

The state is generated with nothing but those three, and a TODO. What a screen shows is the screen's business, and a scaffolded List<OrderModel> items that half the features do not want is a line to delete rather than a head start.

One class rather than a sealed state per phase, because the data outlives the phase. A screen that keeps its list up while a save runs leaves the status on success; with a class per phase, that list has to be re-declared on every phase that can show it, and the view grows a body per shape. Here the view has one — and does not switch at all:

builder: (context, state) => AppStatusView(
  status: state.status,
  message: state.errorMessage,
  onRetry: () => context.read<OrdersBloc>().add(const OrdersStarted()),
  isEmpty: state.items.isEmpty,
  skeleton: (context) => const OrdersSkeleton(),
  builder: (context) => _body(context, state),
),

// handed the whole state, whatever the status
Widget _body(BuildContext context, OrdersState state) => ...;

AppStatusView is bloc's half of the pair AppAsyncView is Riverpod's: the same four screens — skeleton, failure, empty, body — reached from the status the state already carries instead of from an AsyncValue. The status lives in core/utils/app_status.dart rather than per feature precisely so one widget can switch over it.

Equatable is load-bearing rather than decorative: bloc drops an emit whose state equals the current one, and BlocConsumer rebuilds — and fires its listener — on the same test. Without value equality every emit is a new object, so every emit repaints — including the Firestore snapshots that changed nothing.

A bloc feature

sealed class OrdersEvent extends Equatable {}
final class OrdersStarted extends OrdersEvent {}   // the route, and the retry
// TODO: one per action the screen can take

class OrdersBloc extends Bloc<OrdersEvent, OrdersState> {
  OrdersBloc(this._repo) : super(const OrdersState()) {
    on<OrdersStarted>(_onStarted);

    on<OrdersDeleted>((event, emit) async {
      emit(state.copyWith(status: AppStatus.loading));
      try {
        await _repo.delete(event.id);
        emit(state.copyWith(
          status: AppStatus.success,
          successMessage: 'Deleted',
        ));
      } on AppException catch (e) {
        emit(state.copyWith(
          status: AppStatus.failure,
          errorMessage: e.message,
        ));
      }
    });
  }
}

No mixin and no base class of moarch's own — that is the whole handler.

The screen is plain flutter_bloc, split across two files. pages/orders_page.dart owns the bloc so leaving the route closes it, and with it anything it holds — it is what a GoRoute points at. views/orders_view.dart draws the state with one switch and reacts to it with one listener, and never touches the locator, so a widget test can pump it with a bloc of its own:

// presentation/pages/orders_page.dart
class OrdersPage extends StatelessWidget {
  Widget build(context) => BlocProvider(
    create: (_) => getIt<OrdersBloc>()..add(const OrdersStarted()),
    child: const OrdersView(),
  );
}

// presentation/views/orders_view.dart — inside OrdersView
BlocConsumer<OrdersBloc, OrdersState>(
  // The two message fields are one-shot, so this fires once each.
  listenWhen: (previous, current) =>
      previous.errorMessage != current.errorMessage ||
      previous.successMessage != current.successMessage,
  listener: (context, state) {
    final error = state.errorMessage;
    if (error != null) AppToast.error(context, error);

    final success = state.successMessage;
    if (success != null) AppToast.success(context, success);
  },
  builder: (context, state) => AppStatusView(
    status: state.status,
    message: state.errorMessage,
    onRetry: () => context.read<OrdersBloc>().add(const OrdersStarted()),
    // presentation/widgets/orders_skeleton.dart — the same rows over
    // BoneMock data, shimmered while the first load runs.
    skeleton: (context) => const OrdersSkeleton(),
    builder: (context) => _body(context, state),
  ),
)

_body takes the whole OrdersState, so a phase that has to draw over data already loaded needs nothing extra — no second body, and no case to add.

listener is the bloc answer to ref.listen: it runs once per new state, which is where a toast, a dialog or a context.push belongs. builder runs on every rebuild, so the same toast raised there would repeat.

AppAsyncView and ref.listenAction are not generated into a bloc project — the first because bloc has AppStatusView instead, the second because BlocConsumer's own listener already does that job. moarch create widget async-view in a bloc project says so rather than writing a file that cannot compile, and create widget status-view says the same in a Riverpod one. Each stack's shared base follows: core/utils/action_notifier.dart on Riverpod, core/utils/app_status.dart on bloc.

AuthState is the exception that stays sealed — AuthInitial (restoring — what parks the router on splash), AuthLoading, AuthAuthenticated, AuthUnauthenticated and AuthFailure. Signed in versus signed out is a real either/or the router guard switches on, and the two carry different things.

Dependencies live in one file

lib/config/di/injector.dart holds every dependency, in both stacks: clients, services, datasources and repositories are constructed once and handed out by type. moarch create feature writes into it at the // moarch:registrations anchor:

getIt.registerLazySingleton<OrdersRepository>(
  () => OrdersRepositoryImpl(getIt<OrdersRemoteDataSource>()),
);
// bloc only. A factory, not a singleton: the screen's BlocProvider creates it
// and closing the route closes it, subscriptions and all.
getIt.registerFactory<OrdersBloc>(() => OrdersBloc(getIt<OrdersRepository>()));

What differs is only the state holder. A bloc is registered like anything else. A Riverpod notifier is not: an AsyncNotifier needs the Ref Riverpod hands it, so it stays behind its provider and reaches into the locator from there —

final ordersNotifierProvider =
    AsyncNotifierProvider<OrdersNotifier, OrdersState>(OrdersNotifier.new);

class OrdersNotifier extends AsyncNotifier<OrdersState>
    with ActionNotifierMixin<OrdersState> {
  OrdersRepository get _repo => getIt<OrdersRepository>();
}

Riverpod holds the state; get_it holds everything the state is built from. So hasInternetProvider, maintenanceStatusProvider, languageProvider, routerProvider and the feature notifiers are still providers — they are state — while the services beneath them come out of getIt.

That anchor comment is load-bearing. Delete it and create feature still generates the feature but says it could not register it; moarch doctor flags it too.

AuthBloc is the one bloc registered as a singleton: the router's redirect and every screen have to read the same session. Its submit events (login, register, logout, delete) are registered with droppable() from bloc_concurrency, so a double tap sends one request.

feature_module.dart is where a feature's long-lived services go: a socket the chat feature keeps open, a call engine. They are not cross-cutting enough for core_module.dart, and they are not repositories. It also carries openScope / closeScope for what one flow owns. A scope is opened when the flow starts, shared by its screens, and disposed when it ends, which a singleton (outlives the flow) or a factory (one per screen) cannot do.

bloc_lint

A bloc project gets bloc_lint as a dev dependency and the recommended ruleset in analysis_options.yaml. Those rules are read by the bloc analysis server rather than by dart analyze, so they need their own run:

dart pub global activate bloc_tools   # once
bloc lint .

The generated CI workflow runs it alongside flutter analyze, and a freshly scaffolded project passes with no findings.

prefer_bloc and prefer_cubit are deliberately left out of the generated ruleset. Features scaffold as event-driven Blocs, but a holder with one value and no vocabulary of events — the locale, the maintenance flag — is a Cubit on purpose, and neither rule can tell the two cases apart.

Dio or Firebase

The backend you pick in the first checklist decides what the data layer is made of. Nothing else about the architecture moves: the same layers, the same class names, the same AppException reaching the same AppAsyncView.

DioFirebase
datasource holdsfinal Dio _dio;final FirebaseFirestore _firestore;
calls go throughsafeApiCallsafeFirebaseCall / safeFirebaseStream
model idintString — a document id
model shapefreezed + json_serializableplus fromDoc, an id kept out of the body, and dates stored as Timestamp
errors mapped byAppException.fromDioErrorfromFirebaseError + fromFirebaseAuthError
auth featuretokens in secure storage, refresh interceptor, the user from GET /auth/meFirebase Auth, email/password + Google

moarch create feature <name> follows the same choice. In a Firestore project the datasource comes out with fetchAll / fetchOne / watchAll / create / save / delete over one collection; in a project with both backends installed, the layer checklist asks which one this feature talks to.

A Firestore feature is live end to end. watchAll is not left on the datasource for you to wire up: the repository exposes it, the notifier subscribes to it in build() and the view renders state.items — so the screen redraws whenever the collection changes, on this device or another, with no pull-to-refresh and no invalidate anywhere. One subscription serves both the first frame and every change after it (taking .first for the initial load and then listening would register the query twice — twice the billed reads), and Riverpod cancels it with the provider. Writes don't touch items either: Firestore applies them to the local cache before the server confirms, and the subscription re-emits. The REST feature is unchanged — a Future, and a TODO where the fetch goes.

Selecting Firebase Auth together with the auth feature generates it against Firebase instead of REST: email/password, Google sign-in, password reset, account deletion, and a session restored from authStateChanges() rather than from a stored refresh token. With Firestore also selected it keeps a users/{uid} profile document in step with the account. Dio is not pulled in for it, and no tokens are stored — Firebase persists the session itself.

Google sign-in needs work outside Dart that nothing in the build will remind you about, so init writes docs/FIREBASE_SETUP.md with all of it: enabling the providers, the Android SHA-1/SHA-256 fingerprints, and the two iOS Info.plist keys (GIDClientID and the REVERSED_CLIENT_ID URL scheme). Those two are written into Info.plist for you when GoogleService-Info.plist already exists; otherwise placeholders go in and moarch doctor --fix copies the real values across once flutterfire configure has run.

Input validation

core/security/validation_service.dart checks a value against an InputType (email, url, phone, password, username, number, creditCard, cardExpiry, cvv, filePath, text) and hands back the cleaned form. It is what AppInput calls, and what AppInputFormat maps onto.

It deliberately does not blocklist SQL keywords and does not HTML-escape what it returns: O'Brien is a name, Create the report is a note, and escaping on the way in is how Tom & Jerry ends up stored as Tom &amp; Jerry. Injection belongs to parameterised queries on the server; escaping belongs to ValidationService.escapeHtml at the point you build HTML. What it does enforce is scoped: shape per type, control characters stripped everywhere, markup in free t

Source 3 files
hooks/register.tsx 158 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { MoarchDevLevel, MoarchDevReport } from '../types'
5import { inspect } from './checks'
6
7const PANE = 'moarch-dev'
8const report = atom({ plugin: 'moarch-dev', key: 'report' } as const, null)
9const isBandHidden = atom({ plugin: 'moarch-dev', key: 'isBandHidden' } as const, false)
10
11const MARK: Record<MoarchDevLevel, string> = { error: '✗', warn: '!', info: '·' }
12const COLOR: Record<MoarchDevLevel, 'error' | 'warning' | 'subtle'> = {
13  error: 'error',
14  warn: 'warning',
15  info: 'subtle',
16}
17
18async function readOr($: EngineInterface, path: string): Promise<string> {
19  try {
20    return await $.fs.read(path)
21  } catch {
22    return ''
23  }
24}
25
26async function git($: EngineInterface, args: string[]): Promise<string> {
27  try {
28    const ran = await $.process.run(['git', ...args], { timeoutMs: 10000 })
29
30    return ran.exitCode === 0 ? ran.stdout : ''
31  } catch {
32    return ''
33  }
34}
35
36/** What the session has cost, as /cost totals it; `n/a` where there is no ledger. */
37async function costOf($: EngineInterface): Promise<string> {
38  try {
39    const usage = await $.session.usage()
40
41    return usage.cost === undefined ? 'n/a' : `$${usage.cost.usd.toFixed(2)}`
42  } catch {
43    return 'n/a'
44  }
45}
46
47/** Re-reads the tree, stores the report and puts its summary on the status line. */
48async function refresh($: EngineInterface): Promise<MoarchDevReport | null> {
49  const pubspec = await readOr($, 'pubspec.yaml')
50  if (!/^name:\s*moarch\s*$/m.test(pubspec)) {
51    return null
52  }
53
54  const found = inspect({
55    pubspec,
56    versionDart: await readOr($, 'lib/src/version.dart'),
57    changelog: await readOr($, 'CHANGELOG.md'),
58    status: await git($, ['status', '--porcelain', '-uall']),
59    headPubspecDiff: await git($, ['show', '-U0', '--format=', 'HEAD', '--', 'pubspec.yaml']),
60  })
61  await update($, report, () => found)
62
63  const loud = found.checks.filter(check => check.level !== 'info').length
64  $.ui.status(loud === 0 ? `moarch ${found.version}` : `moarch ${found.version} · ${loud} to fix`)
65
66  return found
67}
68
69export const register: Register = on => {
70  on('session.start', async ($, e, next) => {
71    await $.command.register({
72      name: 'moarch-dev',
73      description: 'Show the release and template checks for this moarch checkout',
74    })
75    await refresh($)
76
77    return next(e)
78  })
79
80  on('command.run', { command: 'moarch-dev' }, async $ => {
81    await refresh($)
82    await update($, isBandHidden, () => false)
83    await $.ui.open({ id: PANE, title: 'moarch dev' })
84
85    return { text: 'moarch dev pane opened.' }
86  })
87
88  on('turn.complete', async ($, e, next) => {
89    const ran = await next(e)
90    if (e.agentId === undefined) {
91      await refresh($)
92    }
93
94    return ran
95  })
96
97  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
98    const now = await read($, report)
99    const loud = now?.checks.filter(check => check.level !== 'info') ?? []
100    if (e.props.hasSurvey || loud.length === 0 || (await read($, isBandHidden))) {
101      return next(e)
102    }
103
104    const { Box, Button, Text } = $.ui.resolve(e)
105    const first = loud[0]!
106
107    return (
108      <Box>
109        <Text color={COLOR[first.level]}>
110          {MARK[first.level]} {first.text}
111          {loud.length > 1 ? ` (+${loud.length - 1})` : ''}{' '}
112        </Text>
113        <Button
114          key="details"
115          label="Details"
116          onPress={() => $.ui.open({ id: PANE, title: 'moarch dev' })}
117        />
118        <Button key="hide" label="Hide" onPress={() => update($, isBandHidden, () => true)} />
119      </Box>
120    )
121  })
122
123  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
124    const { Box, Button, Text } = $.ui.resolve(e)
125    const now = await read($, report)
126    const cost = await costOf($)
127
128    if (now === null) {
129      return <Text dimColor>Not a moarch checkout: pubspec.yaml does not name moarch.</Text>
130    }
131
132    const templates = now.changed.filter(path => path.startsWith('lib/src/templates/'))
133
134    return (
135      <Box flexDirection="column">
136        <Text bold>moarch {now.version}</Text>
137        <Text dimColor>
138          {now.changed.length} changed file{now.changed.length === 1 ? '' : 's'}
139          {templates.length > 0 ? `, ${templates.length} under templates/` : ''}
140          {now.isHeadBump ? ' · last commit bumped the version' : ''} · session {cost}
141        </Text>
142        <Text> </Text>
143        {now.checks.length === 0 && <Text color="success">✓ Nothing to fix before pushing</Text>}
144        {now.checks.map(check => (
145          <Text color={COLOR[check.level]}>
146            {MARK[check.level]} {check.text}
147          </Text>
148        ))}
149        <Text> </Text>
150        <Box>
151          <Button key="refresh" label="Refresh" onPress={() => refresh($)} />
152        </Box>
153        <Text dimColor>CI: dart format · dart analyze --fatal-infos · version sync · dart test · publish --dry-run</Text>
154      </Box>
155    )
156  })
157}
158
hooks/checks.ts 87 lines
1import type { MoarchDevCheck, MoarchDevReport } from '../types'
2
3/** What the checks read off the working tree. */
4export type Tree = {
5  pubspec: string
6  versionDart: string
7  changelog: string
8  /** `git status --porcelain -uall` output. */
9  status: string
10  /** `git show -U0 --format= HEAD -- pubspec.yaml` output. */
11  headPubspecDiff: string
12}
13
14const RIVERPOD = 'lib/src/templates/riverpod/'
15const BLOC = 'lib/src/templates/bloc/'
16const TEMPLATES = 'lib/src/templates/'
17const GUIDE = [
18  'lib/src/templates/misc/agents_templates.dart',
19  'lib/src/templates/misc/skills_templates.dart',
20]
21
22/** The paths in `git status --porcelain` output, the new side of a rename. */
23export function changedPaths(status: string): string[] {
24  return status
25    .split('\n')
26    .filter(line => line.length > 3)
27    .map(line => {
28      const path = line.slice(3).trim()
29      const arrow = path.indexOf(' -> ')
30      const last = arrow === -1 ? path : path.slice(arrow + 4)
31
32      return last.replace(/^"|"$/g, '')
33    })
34}
35
36/** Reads the tree into the report the dashboard draws. */
37export function inspect(tree: Tree): MoarchDevReport {
38  const version = /^version:\s*(\S+)/m.exec(tree.pubspec)?.[1] ?? '?'
39  const constant = /packageVersion\s*=\s*'([^']+)'/.exec(tree.versionDart)?.[1]
40  const logged = /^## (\S+)/m.exec(tree.changelog)?.[1]
41  const changed = changedPaths(tree.status)
42  const isHeadBump = /^\+version:/m.test(tree.headPubspecDiff)
43  const checks: MoarchDevCheck[] = []
44  const touches = (prefix: string) => changed.some(path => path.startsWith(prefix))
45
46  if (constant !== version) {
47    checks.push({
48      level: 'error',
49      text: `pubspec.yaml is ${version} but lib/src/version.dart is ${constant ?? 'missing'}: CI fails`,
50    })
51  }
52  if (logged !== version) {
53    checks.push({
54      level: 'warn',
55      text: `CHANGELOG.md's newest entry is ${logged ?? 'missing'}, not ${version}`,
56    })
57  }
58
59  const ships = changed.some(path => path.startsWith('lib/') || path === 'bin/main.dart')
60  if (ships && isHeadBump && !changed.includes('pubspec.yaml')) {
61    checks.push({
62      level: 'warn',
63      text: `The last commit already bumped to ${version}: this change needs its own bump (release skill)`,
64    })
65  }
66
67  if (touches(RIVERPOD) !== touches(BLOC)) {
68    const [did, didNot] = touches(RIVERPOD) ? ['riverpod', 'bloc'] : ['bloc', 'riverpod']
69    checks.push({
70      level: 'warn',
71      text: `templates/${did}/ changed but templates/${didNot}/ did not: keep the stacks at parity`,
72    })
73  }
74
75  const isTemplateChange = changed.some(
76    path => path.startsWith(TEMPLATES) && !GUIDE.includes(path),
77  )
78  if (isTemplateChange && !GUIDE.some(path => changed.includes(path))) {
79    checks.push({
80      level: 'info',
81      text: 'Templates changed: if a path, command or state pattern moved, update agents_templates.dart and skills_templates.dart too',
82    })
83  }
84
85  return { version, changed, isHeadBump, checks }
86}
87
types/index.d.ts 26 lines
1/** How much a check matters: `error` fails CI, `warn` breaks a rule, `info` is a reminder. */
2export type MoarchDevLevel = 'error' | 'warn' | 'info'
3
4/** One finding about the working tree. */
5export type MoarchDevCheck = { level: MoarchDevLevel; text: string }
6
7/** What the dashboard, the band and the status line draw. */
8export type MoarchDevReport = {
9  /** `version:` in pubspec.yaml. */
10  version: string
11  /** Paths `git status` reports as changed or untracked. */
12  changed: string[]
13  /** Whether the last commit changed the pubspec's `version:`. */
14  isHeadBump: boolean
15  checks: MoarchDevCheck[]
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    'moarch-dev': {
21      report: MoarchDevReport | null
22      isBandHidden: boolean
23    }
24  }
25}
26