The bobcat Tool
Installing it
dotnet tool install -g Bobcat.Console # first time
dotnet tool update -g Bobcat.Console # after thatbobcat is a global .NET tool, and Bobcat.Console carries the whole free toolset in one package: the run commands, the Event Model import, the resident runner, the Bobcat UI and the queries over it. Most commands start, do their job and exit; ui is the one that stays up until you stop it.
It is an alternative to dotnet test for a spec project. dotnet test still runs the project and tells you pass or fail. bobcat run drives the same test host and shows you the specifications it produced: the Given/When/Then tree, every step with its verdict and timing, every table as a grid, and the reports a failure wrote. bobcat preview shows the specifications a project declares without running anything, because the steps are known at build time.
Both below are real, over the Getting Started and Specifications with Code samples in src/Bobcat.Xunit.Samples, narrowed to one feature each. bobcat run prints the specifications a run produced, then the timing of where the run spent its time:
$ bobcat run src/Bobcat.Xunit.Samples --feature "Calculator, getting started"
Listing Bobcat.Xunit.Samples…
Running the selected specifications in Bobcat.Xunit.Samples…
Feature: Calculator, getting started
═════════════════════════════════════
using sentences OK
─────────────────────────
✓ Given Start with 3
✓ When Multiply by 2
✓ Then The value should be 6 (3ms)
✓ And Adding 2 to 3 should equal 5
Succeeded with Rights: 4, Wrongs: 0, Errors: 0
Duration: 10ms (steps 3ms)
1 specification(s) — all green
Succeeded with Rights: 4, Wrongs: 0, Errors: 0
Timing — 10ms measured across 1 scenario(s)
• using sentences 10ms (100% of measured time) — steps 3ms, outside steps 7ms
step The value should be {number} cost 3ms across 1 occurrence(s)
step Start with {number} cost 0ms across 1 occurrence(s)
step Multiply by {number} cost 0ms across 1 occurrence(s)
lifecycle not measured -- fixture setup belongs to the test runner in the projected lane
All green in 0.6s · host log: /var/folders/…/bobcat-tool/Bobcat.Xunit.Samples.logbobcat preview prints every specification with the steps it declares, each step's template with its placeholders unfilled, and the helper it binds to, because nothing has run:
$ bobcat preview src/Bobcat.Xunit.Samples
Feature: Withdrawals
═════════════════════
a withdrawal is decided
─────────────────────────────────
○ Given an account with a balance of {balance}
↳ AccountSteps.AnAccountWithABalanceOf
○ When {amount} is withdrawn
↳ AccountSteps.IsWithdrawn
○ Then the withdrawal is {outcome}
↳ AccountSteps.TheWithdrawalIs
a withdrawal larger than the balance is refused
─────────────────────────────────────────────────────────
○ Given an account with a balance of {balance}
↳ AccountSteps.AnAccountWithABalanceOf
○ When {amount} is withdrawn
↳ AccountSteps.IsWithdrawn
○ Then the withdrawal is {outcome}
↳ AccountSteps.TheWithdrawalIs
○ Then the balance is {balance}
↳ AccountSteps.TheBalanceIs
a withdrawal larger than the balance is refused narrated
────────────────────────────────────────────────────────────
○ Given an account with a balance of 100
○ When 200 is withdrawn
○ Then the withdrawal is refused and the balance is still 100
○ Then accepted should be false
↳ ShouldBeBooleanExtensions.ShouldBeFalse
○ Then account.Balance should be 100
↳ ShouldBeTestExtensions.ShouldBe
…What do you want to do?
| You want to | Run |
|---|---|
| Run the specs and read them as specifications, not as a test log | bobcat run, or a bare bobcat in the spec project |
| See what a project specifies before running it | bobcat preview |
| Run only what was red last time, or the same selection again | bobcat run --failed, bobcat run --repeat |
| Pick specifications from a menu | bobcat pick |
| Rerun on every change while you work | bobcat watch |
| Run one slice, chapter, domain or pattern of the Event Model | bobcat run --slice …, --chapter …, --domain …, --pattern … |
| Keep a JSON record of the run, with its schema, for CI or a coding agent | bobcat run --results <dir>, or bobcat run --json to stdout |
| Turn results files into one HTML page to open from a downloaded artifact | bobcat run --html <file>, bobcat results-html <dir> |
| Retry what is allowed to be retried, or split the suite across worker processes | bobcat run --retries N, bobcat run --workers N |
| Watch runs live and see the Event Model coloured by them | bobcat ui |
| Keep an application's Event Model live in the UI as you edit it | bobcat watch-event-model <project> |
Turn an eventmodelers.ai board into C# stubs and an EventModelDefinition | bobcat import-event-model <board> |
| Ask about a run or the model from a script or an agent, as JSON | bobcat failing-tests, failing-spec, await-run, slice-coverage, … |
| Let the UI's run buttons drive a suite | bobcat resident <host> |
The commands
run | build a spec project, run it, and show its specifications rather than its log. The default command |
preview | show a spec project's specifications without running them |
pick | choose specifications from a menu, then run them |
watch | run a spec project, and again whenever its source changes |
import-event-model | convert an eventmodelers.ai board export into C#: stub records and an EventModelDefinition per chapter (specifications per example moved to a Wolverine-side command in 1.0.0-rc.2) |
results-html | write the self-contained HTML results page from results files already written |
results-schema | print or write the JSON Schema of the results file |
resident | keep a suite whose process Bobcat does not own available to a run console, running the specifications the console asks for |
ui | serve the Bobcat UI -- the live run board and the Event Model canvas -- on 5525 and open it in the browser, until Ctrl+C |
watch-event-model | keep an application's Event Model live in the Bobcat UI as you edit it, starting a UI if none is running |
list-runs, run-status, failing-tests, failing-spec, flaky-ledger, await-run, export-run | read the runs a running Bobcat UI holds, as JSON -- see Querying Runs from the Command Line |
event-model, slice-coverage | read the Event Model a running Bobcat UI holds, and what of it is untested, as JSON |
The UI came back
The run board and the Event Model canvas lived in Stoat from 2026-09-18 and returned to Bobcat as bobcat ui in October 2026 (#524). Runs publish to it exactly as they always have; see What a Run Publishes.
run, preview, pick, watch
bobcat # run the spec project found from the current directory
bobcat run src/CritterCrush.Specs --feature Booking
bobcat preview
bobcat pick
bobcat watch --scenario "home check"Each takes the spec project (a .csproj, or a directory holding one; found from the current directory when omitted), builds it, and drives its test host. The host's own output goes to a file and the terminal gets the Feature / scenario / step tree. watch reruns on every change to the spec project or anything it references: Enter reruns, p picks, a goes back to everything, q quits. It tells a running bobcat ui how each rebuild went (POST /api/build-status, to every console BOBCAT_MONITOR_URL names), so a build that fails shows on the board with its compiler errors instead of leaving the last green run in view.
| Flag | What it does |
|---|---|
--framework <tfm> | the target framework to run; the newest the project targets by default |
--configuration <name> | the build configuration; Debug by default |
--feature <text> | only features whose title contains this, case-insensitive; repeatable (run, watch) |
--scenario <text> | only scenarios whose title contains this, case-insensitive; repeatable (run, watch) |
--slice <name> | only specifications bound to this Event Model slice; repeatable (run, watch) |
--chapter <name> | only specifications whose slice is in this chapter; repeatable (run, watch) |
--domain <name> | only specifications whose slice is in this domain; repeatable (run, watch) |
--pattern <name> | only specifications whose slice has this pattern (Command, View, Query, Automation, Translation); repeatable (run, watch) |
--trait <Name=Value> | only specifications with this trait: a framework trait or property, or Slice/Chapter/Domain/Pattern; repeatable (run, watch) |
--no-build | use the last build as it is |
--results <dir> | write the run's JSON results file, and its schema, into this directory (not preview) |
--html <file> | also write the run as one self-contained HTML results page (not preview) |
--json | print the results file on standard output instead of the rendered tree; every other line goes to standard error (run, pick) |
--verbose | write every scenario's reports, passing ones too, on every surface: console, results file, wire (not preview) |
--failed | run only the specifications that failed or errored last time (run; see below) |
--repeat | run the last run's selection again, with its flags (run; see below) |
--retries <N> | supervised: retry a failed specification up to N times, if it has a Retry trait or a recovery hint (run; see below) |
--workers <N> | supervised: run on N worker processes, partitioned by test class (run) |
--stall <duration> | supervised: name a specification in flight longer than this, 90s/5m/1h; 5 minutes by default (run) |
--stall-action <action> | supervised: what a stall does, report (default), kill-and-retry or abort (run) |
No short flag means two things. -f would have been --framework, --feature and --failed; -r --repeat, --retries and --results; -s --stall, --stall-action, --scenario and --slice; -c --configuration and --chapter. Every flag that collided is long-only, and a short alias exists only where it is unique within its command: -w (--workers), -d (--domain), -p (--pattern), -t (--trait), -n (--no-build), -j (--json), -v (--verbose), -h (--html). A test in the repository fails on a new collision.
Rerunning what failed: --failed and --repeat
bobcat run # 3 of 40 red
bobcat run --failed # only those 3, by identity
bobcat run --failed # after a fix: whatever is still red
bobcat run --repeat # the last run's selection and flags againEvery bobcat run writes obj/bobcat/last-run.json in the spec project, with or without --results: the failed identities, the selection it ran (identities, empty for everything), its framework, configuration, --results, --html, --json, --verbose and supervision flags, the host's exit code, a timestamp and a reserved seed (null until seeded random order ships). obj/ is git-ignored and dotnet clean removes it.
--failedruns the specifications whose scenario did not succeed — failed or errored — by their{Feature}/{Scenario}identity, with the flags given now. The rerun is itself the last run, so repeating--failednarrows to what is still red.--repeatreplays the last run's selection, framework, configuration,--results,--html,--jsonand--verbose. A--framework/--results/--htmlgiven beside it wins;--json/--verboseadd to the recorded ones. It always builds unless--no-buildis given now.- A specification renamed or deleted since is refused by name, and nothing runs: a rerun that quietly dropped it would read as green. Start over with a plain
bobcat run. - Nothing failed last time:
--failedsays so and exits 0. No last run, or a last run whose host died before writing results (so nothing is known about what failed): refused, exit 1. - Neither combines with
--feature/--scenarioor the coordinate flags (--slice,--chapter,--domain,--pattern,--trait), nor with each other. A run selected by coordinates is repeated as the identities it resolved to.
Supervised runs: --retries, --workers, --stall
bobcat run --retries 2 # retry what is allowed to be retried, twice at most
bobcat run --workers 4 # four worker processes, one test class per lane
bobcat run --stall 2m --stall-action kill-and-retry
bobcat run --failed --retries 2 --results artifacts/bobcatAny of --retries, --workers, --stall or --stall-action makes the run supervised (bobcat#546): the same test host, driven as worker processes by Bobcat's supervisor, the thing a hand-written Supervisor host does in C#. Without any of them bobcat run is exactly the plain run. --results, --verbose and every selection flag work on both.
--retries Nis a ceiling, not a switch. A specification is retried only when it carries aRetrytrait ([Trait("Retry", "2")]) or a recovery hint ([ClearsOnRetry(typeof(TimeoutException))]) that matches the failure. Without either, a failure is reported once,--retriesor not: retrying every failure would make flaky indistinguishable from broken. ASpecCatastrophicExceptionis never retried and aborts the run. ARetrytrait's retry runs alone in a fresh process;[Trait("RetryWarm", "true")]retries in the same process instead.- A pass on retry exits 0 and is reported apart: the report after the specifications lists "Passed on retry (not clean passes)" and the quarantine candidates. Each pass on retry says what its failed attempt failed with (
first failed with System.TimeoutException: … (attempt 1)), which is the evidence for keeping it on retries or treating it as a bug.--faileddoes not count it: the last verdict for a specification is the one that counts. --workers Nruns N lanes, each a worker process, partitioned by test class, never by test: a class's fixtures and static state stay in one process. Classes that share a database still collide; give each lane its own database before raising it.- There is no default per-specification timeout; that is the test framework's job. A supervised run instead names a specification that has been in flight for 5 minutes (
--stallchanges that) in a "Stalled" section of the report, and does nothing else unless--stall-actionsayskill-and-retry(kill the worker, run the stalled specification once more alone, resume the rest) orabort(stop the run). - The tool exits 0 when everything passed, on retry included, and non-zero otherwise. The last line tells a failure ("Specifications failed") from a run that was aborted or whose worker died ("never established");
obj/bobcat/last-run.jsonrecords the supervisor's exit code (1 failed, 2 aborted or unknown). - Each worker renders its own specifications, so a specification retried in a fresh process is shown once per attempt; the report says which attempt counted. The supervisor's own log is named on the last line.
--jsonis refused for a supervised run: it writes one results file per worker request, so there is no single file to print. Use--results <dir>.--repeatreplays the supervision flags with the rest.
The results file
A run asked for one writes one JSON file per run holding every scenario it finished: the specification identity ({Feature}/{Scenario}), the verdict, every step with its status, timing, logs and cells (expected / actual / note, or value for an input), set-verification grids, the reports the visibility rule lets through, and the suite's timing block. It is the artifact a CI job archives and an agent reads when no console was running; TRX knows nothing about steps or cells.
bobcat run --results artifacts/bobcat # the tree as usual, plus the file
bobcat run --json | jq '.scenarios[] | select(.succeeded | not) | .id'
BOBCAT_RESULTS=artifacts/bobcat dotnet test # the same file from plain dotnet testBOBCAT_RESULTS=<dir>is the switch; the flags only set it for the host they launch, so any runner (xUnit, TUnit, NUnit, MSTest) writes the file under plaindotnet test. Unset writes nothing.- The file is
<suite>-<runId>.json. A second file for the same run id (supervised workers shareBOBCAT_RUN_ID) is-2,-3, never an overwrite. A warm process writes one per run request. - Scenarios are ordered by feature, then title, so two runs of one suite diff cleanly. A skipped test is not in the file: it asserted nothing.
- It has a JSON Schema —
bobcat-results-1.0.schema.json, generated from the records the file is written from, shipped in theBobcatpackage underschemas/, and written beside every results file. The file's$schemanames it, so an archived artifact validates offline.bobcat results-schemaprints it (--out <dir>writes it).schemaVersionmoves only for a breaking change; new optional fields keep it.bobcat results-schema --baseline <bobcat-results-<version>.schema.json>compares this tool's schema with an older one and fails on a breaking change (a removed property, a changed type, a newly required property) unless the schema version moved. - A passing scenario's reports are in the file only with
--verbose/BOBCAT_VERBOSE_REPORTS=1, the same rule as the console and the wire. - Writing it never fails a run: a directory that cannot be written is one line on standard error.
The HTML results page
One run as one HTML file, for CI to archive and for someone to open later from disk or a downloaded artifact. There is no server behind it and it fetches nothing: the page's script, its styles and the run's results are all inside the file. It is an archive, not a console; watching a run live is bobcat ui's job.
bobcat run --html artifacts/bobcat/results.html # the run, as a page
bobcat run --results artifacts/bobcat --html artifacts/bobcat/results.html # with its attachments
BOBCAT_RESULTS=artifacts/bobcat dotnet test # plain dotnet test, then:
bobcat results-html artifacts/bobcat # artifacts/bobcat/results.html- Opt-in only. Nothing writes a page by default.
--html <file>asksbobcat run(andpick,watch) for one;bobcat results-html <files or directories> [--out <file>]writes one from results files already written — a plaindotnet testwithBOBCAT_RESULTS, a supervised run's one file per worker, a CI artifact downloaded later. Every results file it is given goes on one page, counted together — so a supervised run's retried specification is there once per attempt. With no--outthe page isresults.htmlin the first directory given. - It renders a scenario exactly as
bobcat uidoes: the same Vue components, built into the tool, show the narrative, each step's cells and grids, missing and extra rows, steps the run never reached, exceptions, logs and reports. Failing scenarios start open; a filter narrows to the failing ones or by name. A failing Acceptance specification is shown as not yet passing, never as a failure. - The page is built from the results file, so what the file leaves out the page cannot show: a passing scenario's reports need
--verbose, and an exception carries its type and message but no stack. - Attachments stay files. A screenshot is drawn inline under the step that took it, from the results directory, through a link relative to the page — keep the page and the results directory together when you archive them.
bobcat run --htmlwithout--resultswrites its results to a temporary directory it deletes, so that page names the attachments and links none. - Writing it never fails a run: the specifications decide the exit code. A tool built from source without
-p:EmbedFrontend=truehas no page to write and says so; the packed tool always has it.
Selecting by Event Model coordinates
bobcat run --slice "Deposit funds"
bobcat run --chapter "Money in" --pattern Command
bobcat watch --slice "Deposit funds" --slice "Withdraw funds"
bobcat run --trait Category=SlowThe spec-driven loop works slice by slice, so a run can be narrowed to a slice, a chapter, a domain or a pattern (bobcat#573). A repeated flag ORs and different flags AND, so --slice A --slice B --pattern View means "slice A or B, and a View". There is no expression language and no NOT. --feature and --scenario narrow further on top.
The coordinates come from the suite's spec manifest. Each entry carries the slice the generator folded the specification into, and that slice's chapter, domain and pattern. These are the values the Event Model descriptor gets: the slice from [BobcatSpec]/[BobcatSlice] (or the feature title for a spec with vocabulary roles and no binding, or its command's name when the feature's unbound specs make different acts), the chapter and domain the slice's first specification declared, and the pattern as declared or as derived. A spec whose act sends a command is Command even though nothing says so (Automation when what it receives is an event another spec emits or arranges); one whose act names no command is Command when the act is an HTTP write and has no pattern otherwise. A spec on no slice (BOBCAT041) has no coordinates. The selection becomes a method filter, the same as --feature.
Values match whole, ignoring case, and never as a substring: --slice Deposit does not also run "Deposit refund". A value no specification has is refused by name, and the message lists the values that exist:
Refused: No specification is bound to the slice 'Withdraw funds'. The slices with specifications are: AccountSummary, Deposit funds.The run never goes ahead with nothing selected, because a filter that matches nothing runs nothing and reads as green. A combination that each value exists for but no specification satisfies is refused the same way ("each of those exists, but never together").
It is not that the slice is misspelled when the spec binds its slice through a named constant (SliceName = MySlices.Deposit). The generator reads only a literal or typeof(...) there, the same as for the Event Model, so such a spec has no coordinates. Write the literal, or bind by type.
--trait Name=Value matches the traits the test declares (xUnit [Trait], TUnit and NUnit [Property], NUnit [Category], MSTest [TestProperty]/[TestCategory], on the method, class or assembly) and the coordinates under the names Slice, Chapter, Domain and Pattern. So --trait Slice=X means what dotnet test --filter-trait "Slice=X" means.
The same coordinates as platform traits
The runner adapters project the same four values onto the tests, so a plain dotnet test, IDE grouping, TRX and the supervisor's discovery see them with no Bobcat tool involved. They read the same generated binding the manifest is written from, so the two cannot disagree. A coordinate that is null (no chapter, a pattern nothing can tell) is left off rather than projected as an empty value.
| Framework | Projected as | Filter |
|---|---|---|
| xUnit v3 | traits from [BobcatSpec] (an ITraitAttribute) | dotnet test --filter-trait "Slice=Deposit funds" |
| TUnit | properties, added at discovery by [BobcatScenario] | --treenode-filter "/*/*/*/*[Slice=Deposit funds]" |
| NUnit | properties, from [BobcatScenario] (IApplyToTest) | --filter "Slice~Deposit" |
| MSTest | not projected | none yet: bobcat run --slice refuses NUnit and MSTest suites too |
- xUnit: only
[BobcatSpec]carries them, not[Fact]+[BobcatScenario]. xUnit asks a trait attribute for its traits without saying which method it is on, so the attribute finds its method by the source location the compiler stamped on it. - NUnit: on a class-level
[BobcatScenario], the fixture gets only the coordinates every bound test in the class shares. NUnit applies a fixture's attributes before it builds the fixture's tests, so there is no test to stamp yet. NUnit's filters match a test through its fixture, so "one class, one slice" selects as expected. For each test's own coordinates in a mixed class, put[BobcatScenario]on the methods. - MSTest: not projected.
Bobcat.MSTestreferences no MSTest assembly, and its hooks run when a test runs, which is too late for a discovery-time filter.bobcat run's coordinate flags don't cover it either: running an NUnit or MSTest suite by identity is refused until a filter spelling has been verified against a real host.
import-event-model
bobcat import-event-model board.yaml --model K9CrushReads an eventmodelers.ai board export, segments it into slices, and writes the design out as C#:
chapter 'TheSwiper': Command slice 'SwipeOnDog' triggered by 'Discovery Feed'.
chapter 'TheSwiper': Automation slice 'DetectMutualMatch' triggered by 'Dog Liked'.
chapter 'TheSwiper': View slice 'MatchList'.
chapter 'TheSwiper': View slice 'MatchList' consumes 3 event(s): DogLiked, DogPassed, MutualMatchDetected.
3 slice(s) from 1 chapter(s).
⚠ inferred aggregate Dog for DogLiked, DogPassed: 'Dog' is the subject 2 of its 2 events share.
⚠ inferred aggregate MutualMatch for MutualMatchDetected: named for the subject of MutualMatchDetected, its only event.
⚠ slice 'SwipeOnDog': inferred that it decides against Dog — its examples give Dog events before it appends; 'Dog' is the subject 2 of its 2 events share.
⚠ slice 'DetectMutualMatch': inferred that it decides against MutualMatch — its examples give MutualMatch events before it appends; named for the subject of MutualMatchDetected, its only event.
Wrote 5 stub file(s) under …/K9Crush/Features.
Wrote 1 event model definition(s): Features/TheSwiper/TheSwiperModel.cs. Register them with AddEventModel<T>() (or let Wolverine discover them). The segmentation above is a set of guesses, so correct one with an edit rather than a re-import.
Model 'K9Crush': 3 slice(s), 2 bound specification(s).That is the import of k9crush.yaml, one of the boards whose import ImportFixtureTests keeps. The C# on this page is what those imports wrote.
Pass --out the application project's directory; everything lands under it.
The stubs: one file per slice, a folder per chapter
Each slice gets Features/{Chapter}/{Slice}.cs, in the namespace {Namespace}.{Chapter}. The file holds what the slice produces: its command (or the read model of a view), the events it emits and the messages it publishes. That is also the file wolverine scaffold adds the handler to, so a command and its handler live side by side. A type no slice produces, such as an aggregate or an event consumed from elsewhere, gets a file of its own under the chapter of the first slice that names it. Features/TheSwiper/SwipeOnDog.cs:
// Imported from an event model by `bobcat import-event-model`.
//
// A stub has only the fields the model names, typed from what it declares or from its
// example values, plus an Id where the model names no identity. Add the rest as the
// behaviour takes shape. Nothing regenerates this file, so your edits are safe.
using System;
namespace K9Crush.TheSwiper;
/// <summary>
/// the command of SwipeOnDog.
/// </summary>
public record SwipeOnDog(Guid Id);
/// <summary>
/// emitted by SwipeOnDog.
/// consumed by MatchList.
/// </summary>
public record DogLiked;
/// <summary>
/// emitted by SwipeOnDog.
/// consumed by MatchList.
/// </summary>
public record DogPassed;A stub has only what the board names, plus an Id where it names no identity. An emlang export usually carries no field information, since the board's props are intentionally omitted. Events stay field-less. A command, an aggregate or a read model gets Guid Id when the model marks no identity for it, because that is Wolverine's own convention: a specification can address the stream through it, and a handler's [WriteAggregate] resolves it with nothing declared.
A command that starts a stream gets no Id (bobcat#453). Its handler mints the new stream's id, a sequential Guid (Guid.CreateVersion7()), so an Id on the command would be a member nothing reads. An example that gives the command an id keeps it. A stream keyed by a string, a natural key such as an email, is the exception: nothing can mint one, so the command carries string Id and the new stream takes it.
A swimlane is not a stream. In Admin / Volunteer approved, Admin is who acts, not where the event is stored.
Aggregates: declared, or inferred and called out
A command that does not only start a stream needs a DCB decider or one or more single-stream aggregates, so the import gives every command one (bobcat#444). A stream the model declares (an eventmodelers.ai element's aggregate) is used as it is. Everything else is inferred from the examples:
- Lineage groups events into streams. An example that gives an event and expects another says the second is appended where the first was. When an example gives events of several subjects (
Home check requestedandVolunteer approved), what it expects joins only the givens whose name shares a subject with it, so one decision drawing on two streams never folds them into one. - A stream is named for its subject: the longest run of words most of its events open with.
AppointmentConfirmed,AppointmentCancelledandHomeCheckAppointmentProposedare anAppointment. A stream no example links to anything joins one whose name its subject ends with (FosterHandoverAppointmentis anAppointment), and the report says that was by name only. - A slice starts a stream when it appends to it and no example gives it an earlier event there:
.StartsStream<VolunteerApplication>(). Otherwise it decides against it:.Against<VolunteerApplication>(). - A decision drawing on several streams decides against each, its command carries an
{Aggregate}Idper stream, and it is flagged: choose several[WriteAggregate] IEventStream<T>parameters or a DCB decider (bobcat#443). - A command left with no aggregate is reported as missing, with a TODO on the slice. It is never quietly made aggregate-less.
Every inferred aggregate is called out, in the report and as a comment on the slice in the definition, so a wrong guess is a one-line edit there. From the import of shelter.yaml, where Appointment is also the chapter's default aggregate (below), so the slice needs no .Against<Appointment>():
// From chapter 'Booking', actor 'Member'.
// ⚠ inferred: decides against Appointment — its examples give Appointment events before it appends; 'Appointment' is the subject 3 of its 3 events share.
model.Command<ConfirmAppointment>()
.Emits<AppointmentConfirmed>()
.LinksToSpecification("ConfirmAppointment/confirmed");Or say it outright on the import, which always wins over the inference. Several at once go after one flag:
bobcat import-event-model board.yaml --aggregate ConfirmAppointment=Booking AcceptHomeCheckAssignment=HomeCheck--starts-stream Slice=Type says that a slice starts a stream (bobcat#454). The slice's --aggregate overrides are then the aggregates it decides against besides, and what its examples give first is never joined to the stream it starts.
Import part of a board with --chapter. --chapter Volunteering Booking imports only those chapters. The rest of the board is still read, as evidence. When the only events an example gives before a command acts come from a chapter you left out, nothing imported shows the stream existing first, so the command may well start it. The import asks:
⚠ slice 'RequestHomeCheck' starts the HomeCheck stream? It is inferred to decide against HomeCheck, but the only HomeCheck events its examples give first, ApplicationReviewed, come from chapter 'ShelterReviewsApplication', which was not imported. If it starts the stream, import with --starts-stream RequestHomeCheck=HomeCheck.The same question is asked, without a chapter name, when no slice on the board emits the event.
--json for tooling (bobcat#454). With --json, the files are written as usual but stdout carries nothing but JSON: every callout, the text report, and every file, marked write, merge or kept. Each callout has a stable code, the aggregates it is about, the evidence it was drawn from, and the flag that settles it:
{
"slice": "RequestHomeCheck",
"code": "stream-start-from-unimported-chapter",
"aggregates": [
"HomeCheck"
],
"message": "\u26A0 starts the HomeCheck stream? It is inferred to decide against HomeCheck, but the only HomeCheck events its examples give first, ApplicationReviewed, come from chapter \u0027ShelterReviewsApplication\u0027, which was not imported. If it starts the stream, import with --starts-stream RequestHomeCheck=HomeCheck.",
"evidence": {
"events": [
"ApplicationReviewed"
],
"examples": [
"requested"
],
"chapters": [
"ShelterReviewsApplication"
]
},
"override": "--starts-stream RequestHomeCheck=HomeCheck"
}Non-ASCII characters such as ⚠ and — arrive escaped (\u26A0), as any JSON parser reads them.
| Code | Means | Settled by |
|---|---|---|
inferred-stream-start | No example gives the slice an earlier event on the stream, so it starts it | --starts-stream Slice=Type |
inferred-against | Its examples give the aggregate's events first, so it decides against it | --aggregate Slice=Type |
stream-start-from-unimported-chapter | Decides against an aggregate only on evidence nothing imported emits; it may start the stream | --starts-stream Slice=Type |
stream-start-unknown | No example says whether it starts the stream, so it is assumed to append | --starts-stream Slice=Type |
several-streams | Draws on several streams: several [WriteAggregate] streams or a DCB decider | a design decision, no flag |
missing-aggregate | No aggregate could be declared or inferred | --aggregate Slice=Type |
strong-typed-id-name-taken | --strong-typed-ids was asked for and the board already names a type {Aggregate}Id, so that aggregate keeps a primitive identity | rename the board's element |
merged-slice-declaration | A re-import declared a new slice at the end of its chapter's definition | nothing; review it |
merged-aggregate-declaration | A slice merged in names an aggregate no definition declared, so it was declared beside it | nothing; review it |
merged-type | A re-import added a type the board names to an existing stub file | nothing; review it |
type-differs-from-board | A type lacks a member the board names, or types it differently. The type was not touched | edit the type, if the board is right |
merged-slice-roles | The board gives an existing slice roles (Emits, Publishes, On, Reads, Produces, Projects) no declaration of it says, so a statement reopening the slice by name was added at the end of the definition's Configure. roles lists them | nothing; review it |
slice-differs-from-board | The board gives an existing slice roles no declaration says, and they could not be added: the slice is declared in a file other than its chapter's definition | the fix: the calls to add, and where |
slice-aggregate-differs | The board's aggregate for an existing slice (the stream it starts, what it decides against) isn't what its declaration says. Nothing was changed | the fix, if the board is right; the override flags, when the board's aggregate is inferred and the code is right |
slice-trigger-differs | The board's trigger for an existing slice (its label or kind) isn't what its declaration says. Nothing was changed | the fix, if the board is right |
slice-pattern-differs | The board's pattern for an existing slice isn't what its declaration says. Nothing was changed | the fix, if the board is right |
slice-role-removed-on-board | A declaration says a role the board doesn't give the slice. Nothing was removed | the fix, if the board is right; nothing, if you added the role by hand |
slice-removed-on-board | A definition declares a slice the board doesn't have. Nothing was removed | the fix, if the board is right; nothing, if you declared the slice by hand |
merge-skipped-unparseable | An existing file does not parse, so nothing was merged into it | fix the file and import again |
merge-skipped-no-insertion-point | The namespace, definition class, Configure method or feature class to insert into is not in the file | add it by hand |
merge-skipped-ambiguous | More than one place could take the addition, so none was chosen | add it by hand |
Callouts about the project rather than a slice have an empty slice, a file naming what they are about, and a fix holding the exact line, package or command. The merge callouts carry a file, a type when they are about one type, roles (each as Emits(AppointmentNoShowRecorded)) when they are about a slice's roles, and a fix when there is an edit to make by hand.
The messages may be reworded; a code never is, and codes are only ever added (the same contract as wolverine scaffold --json).
The callouts about a spec project (spec-project-*, app-*, global-json-missing-test-runner, central-package-versions, merged-spec, merged-using) belong to the specification writer, which moved to the Wolverine repository in 1.0.0-rc.2; this command no longer raises them.
Each chapter gets one EventModelDefinition, beside its stubs in Features/{Chapter}/{Chapter}Model.cs, declaring that chapter's slices through the JasperFx.Events fluent API. The chapter is said once at the top, and so is the aggregate most of the chapter's commands decide against. The shelter board's Features/Booking/BookingModel.cs, where the two automations start the Appointment stream (a slice that starts a stream ignores the default) and the two commands decide against it:
// Imported from an eventmodelers.ai board by `bobcat import-event-model`.
//
// This is ordinary C# and nothing regenerates it. The segmentation an import performs is a
// set of reported guesses, so a wrong guess is a one-line edit here rather than a re-import.
using JasperFx.Events.EventModeling;
using Shelter;
using Shelter.Volunteering;
namespace Shelter.Booking;
public class BookingModel : EventModelDefinition
{
public override void Configure(EventModelBuilder model)
{
model.InChapter("Booking");
// ⚠ inferred: 2 of the 2 commands here decide against Appointment, so it is the
// default; .Against<T>() on a slice replaces it, and .NoAggregate() says it has none.
model.ForAggregate<Appointment>();
// From chapter 'Booking', actor 'System'.
// ⚠ inferred: starts the Appointment stream — no example gives it an earlier Appointment event; 'Appointment' is the subject 3 of its 3 events share.
model.Automation("ProposeHomeCheckAppointment")
.TriggeredBy(TriggerKind.MessageHandler)
.Command<ProposeHomeCheckAppointment>()
.StartsStream<Appointment>()
.Emits<HomeCheckAppointmentProposed>()
.LinksToSpecification("ProposeHomeCheckAppointment/home check proposed");
// From chapter 'Booking', actor 'System'.
// ⚠ inferred: starts the Appointment stream — no example gives it an earlier Appointment event; 'Appointment' is the subject 3 of its 3 events share.
model.Automation("ProposeFosterHandoverAppointment")
.TriggeredBy(TriggerKind.MessageHandler)
.Command<ProposeFosterHandoverAppointment>()
.StartsStream<Appointment>()
.Emits<FosterHandoverAppointmentProposed>()
.LinksToSpecification("ProposeFosterHandoverAppointment/foster proposed");
// From chapter 'Booking', actor 'Member'.
// ⚠ inferred: decides against Appointment — its examples give Appointment events before it appends; 'Appointment' is the subject 3 of its 3 events share.
model.Command<ConfirmAppointment>()
.Emits<AppointmentConfirmed>()
.LinksToSpecification("ConfirmAppointment/confirmed");
// From chapter 'Booking', actor 'Staff'.
// ⚠ inferred: decides against Appointment — its examples give Appointment events before it appends; 'Appointment' is the subject 3 of its 3 events share.
model.Command<CancelAppointment>()
.Emits<AppointmentCancelled>()
.LinksToSpecification("CancelAppointment/cancelled");
}
}A chapter whose commands have no clear majority aggregate gets no default, and every command says its own. The definitions set no Name, so every chapter joins the application's model. Pass --single-definition for one <Model>.cs holding every slice, each with its own .InChapter(…).
Register each with services.AddEventModel<BookingModel>() and it joins the model on the Declared rung, where a claim the code derives always wins and any difference between the two shows up as a SourceDisagreement hotspot. That gap is the design-first to-do list.
--strong-typed-ids: an identity type per aggregate
Without it an identity the model does not type is a Guid, which is what every Critter Stack store defaults a stream id to. With it each aggregate gets one, in Identities.cs in the root namespace. From the import of shop.yaml with --strong-typed-ids:
// Imported from an event model by `bobcat import-event-model --strong-typed-ids`.
//
// One identity type per aggregate, wrapping the identity its stream would otherwise have.
// Nothing regenerates this file, so your edits are safe.
#nullable enable
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Shop;
/// <summary>The identity of Order, and of everything that addresses its stream.</summary>
[JsonConverter(typeof(OrderId.Converter))]
public readonly record struct OrderId(Guid Value) : IParsable<OrderId>
{
public override string ToString() => Value.ToString();
public static OrderId Parse(string s, IFormatProvider? provider) => new(Guid.Parse(s));
public static bool TryParse(string? s, IFormatProvider? provider, out OrderId result)
{
var parsed = Guid.TryParse(s, out var value);
result = new OrderId(value);
return parsed;
}
/// <summary>So the wire carries the value, not an object wrapping it.</summary>
public sealed class Converter : JsonConverter<OrderId>
{
public override OrderId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> new(reader.GetGuid());
public override void Write(Utf8JsonWriter writer, OrderId value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.Value);
}
}Everything that addresses that stream then takes it — the aggregate's Id, the command's Id or {Aggregate}Id, the identity member of each of its events (including one the board marks, such as an Email keying a customer) and the read model projected from it, and the definition declares it, which is a Declared claim the store's own projection later agrees with (jasperfx#1004):
model.ForAggregate<Order>().IdentifiedBy<OrderId>();Three things worth knowing:
- The name is the requirement. Marten treats a type as a strong-typed identity only when its name ends in
Id(case sensitive), so{Aggregate}Idis not a style choice. The struct wraps what the stream's id would otherwise have been — aGuid, or astringfor a natural key. - A read model projected from the stream is keyed by the same type. A single-stream projection's document id must be the stream's identity type, so
OrderSummary(OrderId Id)is correctness, not tidiness. - An aggregate whose
{Aggregate}Idthe board already uses as an element keeps a primitive id, and the import says so (strong-typed-id-name-taken): two types of one name in one namespace is output that does not build.
Write your Bobcat specs against the stubs straight away. They are red until the behaviour exists, and that is the point.
Specifications from the board moved to the Wolverine repository in Bobcat 1.0.0-rc.2 (bobcat#744). The --specs option wrote one WolverineFx.Bobcat specification per example on the board, and the spec project around them, and checked the application's Wolverine setup -- so it belongs next to wolverine scaffold, and lives there now as a Wolverine-side command. --specs, --specs-out, --specs-namespace, --store, --app-project, --bobcat-version and --wolverine-version are gone from this command. The stubs and the EventModelDefinition it writes are unchanged, and a spec written by hand or by the Wolverine command binds to them the same way.
A spec class bound with [BobcatSlice(SliceType = typeof(ReviewVolunteerApplication))] (or SliceName = "…" where no type bears the slice's name) lets the IDE navigate from the spec to its slice. Bobcat.Generators also writes a manifest of every [BobcatFeature] test as a JasperFx SpecificationBindingDescriptor: its {Feature}/{Scenario} identity, and the command its first When…(Specify<T>()…) or When…(new T(…)) sends, or the slice [BobcatSlice] names. The manifest is marked with JasperFx's [assembly: SpecificationManifest(…)], so it can be read without referencing Bobcat: JasperFx.Events.EventModeling.SpecificationManifest.In(assembly), or .Load(path) for an assembly on disk (Bobcat's own SpecificationBindings.In was removed in 1.0.0-rc.2). JasperFx's EventModelSpecifications.Link joins those onto an assembled Event Model by command type (and domain, for a command several modules handle), so an EventModelDefinition needs no LinksToSpecification.
Wolverine's describe-event-model (and the event-model export) runs that join: pass --specs the built spec assembly, once per spec project, and the model it describes carries each slice's specifications. A command several slices could own shows up as an UnresolvedSpecification hotspot.
dotnet run -- describe-event-model --specs ../Shelter.Specs/bin/Debug/net10.0/Shelter.Specs.dllThe links in the definition are the identities those specs report: the slice name, then the method name read back as a sentence. That's why the example ALikeIsRecorded is linked as SwipeOnDog/a like is recorded. A projected test's scenario title is its method name, so linking the board's own spelling would bind to a scenario no run ever reports, and every slice would stay unproven with all of its specs green. An example that the board attaches to no slice is linked under a feature named for its chapter, and the report flags that it binds to nothing.
Nothing regenerates these files
The segmentation is a set of reported guesses -- which is why it writes files rather than acting on what it inferred. A wrong guess is a one-line edit in the generated C#, not a re-import, and since the command never rewrites a file it has already written, your edits cannot be clobbered.
Re-running the import
Without --force, a re-import is additive. A file that doesn't exist is written. A file that does exist gets what the board has and the file lacks, and nothing in it is changed or removed:
| File | What is added | What is never touched |
|---|---|---|
The chapter's EventModelDefinition | a declaration for each slice no definition in --out declares, at the end of Configure, written to agree with the file's own InChapter / InDomain / ForAggregate; a model.Aggregate<T>() for an aggregate such a slice names that nothing declares; a statement reopening an existing slice by name with the roles the board now gives it (see below) | every existing declaration, even where the board now says something else (the slice-* callouts say what) |
A slice's stub file (Features/{Chapter}/{Slice}.cs) | each type the board names that no file in --out declares, at the end of the file's namespace, plus any using it needs | every existing type, even where its members differ from the board's (type-differs-from-board) |
- The whole directory is checked, not just the file. A type or slice you moved to another file is found there and never declared twice. A type a new stub file would declare is left out of it when another file already declares it.
- Roslyn finds the insertion point; the edit is a text insertion. Every byte you wrote stays where it was, whatever your formatting. A file that doesn't parse, a missing class or namespace, or more than one candidate is skipped with a
merge-skipped-*callout. Nothing is forced. - A type is compared on the members the board names. Members you added are not a difference. A board member missing from the type, or typed differently, is.
- It is idempotent. Run it twice and the second run changes nothing.
- Each merge prints a line, e.g.
Added 1 slice declaration (SubmitHomeCheckReport) to Features/VolunteeringAndHomeChecks/VolunteeringAndHomeChecksModel.cs.orAdded 1 role (RecordAppointmentNoShow: Emits(AppointmentNoShowRecorded)) to Features/Booking/BookingModel.cs., and--jsonlists the file with"mode": "merge". --forcestill overwrites, as before.
A new role on an existing slice
When the board gives a slice the definition already declares a new event, message, consumed event, read, read model or projection, the stub file gains the type and the definition gains a statement at the end of Configure that reopens the slice by name with just the missing roles. Here noshow-v1.yaml was imported, then noshow-v2.yaml, which gives RecordAppointmentNoShow a second event:
// Imported from an eventmodelers.ai board by `bobcat import-event-model`.
//
// This is ordinary C# and nothing regenerates it. The segmentation an import performs is a
// set of reported guesses, so a wrong guess is a one-line edit here rather than a re-import.
using JasperFx.Events.EventModeling;
using Shelter;
namespace Shelter.Booking;
public class BookingModel : EventModelDefinition
{
public override void Configure(EventModelBuilder model)
{
model.InChapter("Booking");
model.Aggregate<Appointment>();
// From chapter 'Booking', actor 'Member'.
// ⚠ inferred: starts the Appointment stream — no example gives it an earlier Appointment event; 'Appointment' is the subject 2 of its 2 events share.
model.Command<BookAppointment>()
.StartsStream<Appointment>()
.Emits<AppointmentBooked>()
.LinksToSpecification("BookAppointment/booked");
// From chapter 'Booking', actor 'Staff'.
// ⚠ inferred: decides against Appointment — its examples give Appointment events before it appends; 'Appointment' is the subject 2 of its 2 events share.
model.Command<RecordAppointmentNoShow>()
.Against<Appointment>()
.Emits<AppointmentMissed>()
.LinksToSpecification("RecordAppointmentNoShow/missed");
model.Slice("RecordAppointmentNoShow").Emits<AppointmentNoShowRecorded>();
}
}The declaration itself is never edited. JasperFx folds every statement naming a slice into one slice and unions their roles, in one definition or across several, so the model and wolverine scaffold see RecordAppointmentNoShow emitting both events, and scaffold writes the Apply for the new one.
- Several new roles for one slice are one statement, a call per line.
- A role is already there if any statement says it, the declaration, an earlier reopening statement, or one you moved to another definition, so a re-run adds nothing. Fold it into the declaration and delete the reopening statement if you prefer; the next import won't add it back.
- A declaration that names its own chapter or domain (
.InChapter("Elsewhere")) is reopened in that chapter, or the two statements would disagree. merged-slice-rolessays what was added. A slice declared in another file than its chapter's definition is not reopened;slice-differs-from-boardgives the calls to add there.
What a re-import reports and never changes
Comparing the board's declaration with the file's, declared to declared, a re-import reports, and never applies:
| Code | When |
|---|---|
slice-aggregate-differs | the board's aggregate for a slice isn't the declaration's. A board that claims no aggregate is no disagreement, and neither is a declared DCB decider |
slice-trigger-differs | the board states a trigger label or kind the declaration doesn't say. A trigger you added where the board states none is not reported |
slice-pattern-differs | the board's pattern isn't the declaration's |
slice-role-removed-on-board | a declaration says a role the board doesn't give the slice |
slice-removed-on-board | a definition declares a slice the board doesn't have, including every slice of a chapter the board dropped (any definition in --out carrying the import's header comment is compared). With --chapter, the whole board counts, so a slice the board moved to a chapter left out isn't reported |
Each one's fix is the edit to make by hand, e.g. Delete .Emits<ReminderScheduled>() from the declaration of slice ConfirmAppointment in BookingModel.cs. The board can't tell a removal from something you added yourself, so a role or slice you declared by hand is reported too; leave it.
A re-import is additive because the board keeps moving after the first import, and the generated files are where the corrections live: an import that rewrote them would throw away every edit that made the guesses right.
Two details the generated code is careful about, both of which failed loudly the first time:
- The definition class never shares its namespace's name. The model name supplies both by default, and
namespace K9Crush { class K9Crush }compiles -- then resolves every reference to a sibling stub against the class first, soK9Crush.SwipeOnDogstops meaning what it says. AnEventModelsuffix is added only where that collision is real. - A generic verb is only used where it names the slice the board named.
Command<SwipeOnDog>()names the slice after the type, and the slice name is the merge key -- so where the board called the slice something else, it opens by name and states its pattern and command separately. Otherwise one slice would silently become two.
Roles whose type has no stub -- a handler, for instance, which is behaviour rather than data -- are declared by name: .HandledBy("SwipeEndpoint"). That means the same thing to the merge, which compares a declared type by Name, and it keeps the output building.
The curated .emodel.yaml format is retired
This command used to read and validate a curated event-model file, and an import used to write one for you to review. YAML is no longer an authoring syntax (jasperfx#955): the only YAML Bobcat reads is the Event Modeling platform's own, on import. Hand a curated file to this command and it says so, and points at EventModelDefinition.
Options
| Flag | What it does |
|---|---|
-m, --model <name> | Model name; defaults to the file name. It is the merge key, so it must match what the code-derived sources call the model (opts.ServiceName / [assembly: EventModelName]) or the import floats off as a second diagram |
-n, --namespace <ns> | Namespace for the generated stubs and definition; defaults to the model name |
-o, --out <dir> | Directory the generated C# is written to, normally the application project's; defaults beside the input. Created if it does not exist |
--single-definition | one EventModelDefinition for the whole model instead of one per chapter |
--strong-typed-ids | give each aggregate an identity type — OrderId in Identities.cs — and write every member and IdentifiedBy that addresses its stream with it |
--aggregate Slice=Type … | the aggregate a slice decides against, over the inference |
--starts-stream Slice=Type … | the aggregate whose stream a slice starts, over the inference |
--chapter A B … | import only these chapters; the rest of the board is still read as evidence |
--json | stdout carries only JSON: callouts, report and files |
--force | overwrite generated stubs and definitions that exist, instead of merging into them |
-u, --url <base> | Push the assembled model to a Bobcat UI at this base URL |
--url takes the base, not the endpoint
It appends /api/event-model itself:
bobcat import-event-model board.yaml --url http://localhost:5525Pushed to http://localhost:5525/api/event-model — open http://localhost:5525/event-modelPassing the endpoint gives you …/api/event-model/api/event-model, which fails somewhere less obvious. An unreachable console is reported plainly:
Could not reach http://localhost:5999/api/event-model: Connection refused (localhost:5999)The push also says where the model came from: root, repository and branch query parameters naming the git checkout of the --out directory (#532). A console uses them to tell a model pushed from this checkout apart from a stale one pushed from another; one that predates them ignores them.
resident
bobcat resident ./artifacts/MySpecs # the test host executable
bobcat resident src/MySpecs/bin/Debug/net10.0 # or its build output folder
bobcat resident src/MySpecs/MySpecs.csproj --configuration Release # or the project, as it is builtA resident runner for a spec suite, whose Main belongs to its test framework. It asks the host what it specifies, registers those identities with the console, and runs a command by driving that host narrowed to the specifications the command named -- a fresh process per command (cold), or one host held open between commands (warm), as the console chooses.
What it is pointed at is the spec project's test host: the executable itself, the build output folder holding it, the .csproj, or the directory holding that .csproj. A folder's executables are the ones with a .runtimeconfig.json beside them, and a spec project that references the application it tests has two (bobcat#797). With several, the one the folder's own project builds wins (its AssemblyName, read through MSBuild), then the only one whose .deps.json carries a test framework or Bobcat; a folder still ambiguous after both is refused, naming the candidates. A project is resolved to the host MSBuild says it builds, for --framework (the newest it targets by default) and --configuration (Debug by default) -- nothing is built: a runner is relaunched by whatever rebuilt the suite, and a project whose build is not there is refused with the path it looked at.
| Flag | What it does |
|---|---|
--framework <tfm> | with a .csproj: the target framework whose build to run; the newest the project targets by default |
--configuration <name> | with a .csproj: the build configuration whose build to run; Debug by default |
-u, --url <base> | the console to register with; defaults to BOBCAT_MONITOR_URL, then to the port every publisher probes. It travels down to the child run, so a runner pointed at a second console does not publish to the default one. Several, comma- or semicolon-separated: the runner reads its commands from the first and a commanded run publishes to all of them (bobcat#530) |
-i, --id <id> | a stable runner id. Defaults to BOBCAT_RUNNER_ID, then to a fresh GUID |
--list | print what the host specifies and exit, registering with nothing |
--supervised | a parent relaunches this runner: exit 75 on a restart instead of restarting in place. Also BOBCAT_SUPERVISED=1. No short form |
$ bobcat resident ./src/Bobcat.Xunit.Samples/bin/Debug/net10.0/Bobcat.Xunit.Samples --list
Bobcat.Xunit.Samples (projected/xunit), 90 specification(s):
Acceptance specifications/a wrong is gathered before the behaviour is finished
Acceptance specifications/an acceptance spec that passes is ready to promote
Acceptance specifications/the behaviour throws because it does not exist yet
Actions/custom logging from a step
…It ships here because the console at the other end references nothing else in this repository, and so cannot be handed a class to host. A wiring mistake is refused at launch rather than at the first button press -- a host that is not built, a suite that lists no specifications (usually a spec project with no runner adapter referenced), or a framework whose filter spelling Bobcat will not guess at. A runner that registered and then refused every command would look broken rather than unsupported.
A restart restarts it, whoever launched it (bobcat#796). Run on its own, as above, it reloads the suite (picking up a rebuilt host's specifications) and registers again under the same runner id, in the same process. A parent that relaunches runners itself, such as stoat runner or a container supervisor, passes --supervised (or sets BOBCAT_SUPERVISED=1), and the runner then exits 75 after a restart and 0 on an orderly stop, so that parent can tell "relaunch me" from "I'm done".
Commands
bobcat help # list the commands
bobcat help import-event-model # usage for oneThe tool registers its commands explicitly rather than scanning, so nothing an assembly happens to carry can join this surface. A bare bobcat is bobcat run: it runs the spec project found from the current directory, and fails fast when there is none.
A usage mistake is one line on standard error and exit 1 -- an unknown flag, a value where none is expected, a flag given a value of the wrong type, a missing argument, a project or path that is not there -- naming the command and pointing at its help, never a stack trace or a page of usage:
$ bobcat run --fetaure Booking
bobcat run: Unknown argument or flag for value --fetaure. See 'bobcat help run'.
$ bobcat run --repeat 5
bobcat run: There is no project or directory at '5', and it is not a bobcat command ('bobcat help' lists them). See 'bobcat help run'.Standard output stays empty, so a --json pipe never gets an error message in place of its file. A mistyped command arrives as run's project argument, because a bare bobcat x is bobcat run x; the line says so and points at bobcat help for the commands.
generate-ui-contracts is for Bobcat contributors only: it regenerates the UI's TypeScript copies of the wire contracts (./build.sh UiContracts runs it).
Where this fits
The tool is the front door to the Event Modeling workflow: get a model in as C#, review the segmentation, then write specs against the stubs and build slice by slice. Skeleton generation for declared-only slices is Wolverine's scaffold command (JasperFx/wolverine#4832), not this tool -- Bobcat's own scaffolder was retired with issue #406. See Event Modeling and Spec Driven Development for the whole path, and Spec Identities for the gate that keeps the model and the code honest.