[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6EN93CJ57X0F9N6TF85HSJW":3,"skill-same:01M6EN93CJ57X0F9N6TF85HSJW":74},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":9,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":15,"categories":16,"keywords":22,"publishedAt":44,"linkOnly":45,"markdownContent":46,"markdownBody":47,"frontMatter":48,"refName":50,"commitSha":51,"skillDirectory":52,"forksCount":53,"repositoryHtmlUrl":54,"repositoryHomepageUrl":55,"ownerHtmlUrl":56,"ownerBlogUrl":57,"ownerDescription":58,"updatedAt":59,"install":60,"resources":73},"01M6EN93CJ57X0F9N6TF85HSJW","add-bpm-feature",null,"Step-by-step house pattern for adding a new BPM operations tool, widget tool, or widget to the engine module (`packages\u002Fconnectors\u002Fengine\u002Fengine-connector`, over `packages\u002Fconnectors\u002Fc7\u002Fc7-client` and `packages\u002Fconnectors\u002Fc7\u002Fc7-core`). Use whenever adding or changing Camunda 7 \u002F CIB seven tools (\"add a tool to suspend jobs\", \"expose X from the engine REST API\"), input schemas, cockpit routes, `*_data` feeds, or React widgets in the cockpit. Takes precedence over generic MCP SDK examples.","Miragon\u002Fpulse","Miragon","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F58698887?v=4","01M6EN8W7TCK4BRJB1S2GEW8C3","01M6EN8WC4V4QYAV5C8FMYZ4R9",8,"MIT","https:\u002F\u002Fgithub.com\u002FMiragon\u002Fpulse\u002Fblob\u002Fmain\u002F.claude\u002Fskills\u002Fadd-bpm-feature\u002FSKILL.md",[17],{"categoryNo":18,"categoryName":19,"taxonomyType":20,"taxonomyTypeDesc":21},"01JBSEEDCAT000000000000023","Skill与智能体开发","SCENARIO","使用场景",[23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43],"feature","add","add bpm feature","new","generic","generic mcp","Tools","input","data","pattern","edit","write","read","widgets","packages","react","Connector","api","Bash","MCP","feeds","2026-11-10 10:12:30",false,"---\nname: add-bpm-feature\ndescription: Step-by-step house pattern for adding a new BPM operations tool, widget tool, or widget to the engine module (`packages\u002Fconnectors\u002Fengine\u002Fengine-connector`, over `packages\u002Fconnectors\u002Fc7\u002Fc7-client` and `packages\u002Fconnectors\u002Fc7\u002Fc7-core`). Use whenever adding or changing Camunda 7 \u002F CIB seven tools (\"add a tool to suspend jobs\", \"expose X from the engine REST API\"), input schemas, cockpit routes, `*_data` feeds, or React widgets in the cockpit. Takes precedence over generic MCP SDK examples.\nallowed-tools: Read, Edit, Write, Glob, Grep, Bash\n---\n\n# add-bpm-feature: new tool\u002Fwidget in the engine module\n\nThe engine module never registers operations tools with a raw `server.registerTool()`. Tools go\nthrough the registrar (`createToolRegistrar` from `@miragon\u002Fpulse-widget-shell\u002Fserver`);\nengine access goes through `withEngine`\u002F`resolveEngine` (per-call `engine` override >\n`ENGINE_DEFAULT` > the profile file's `defaultProfile` > the only profile, refused ones counted;\na refused target fails with `ENGINE_PROFILE_REFUSED`, CLAUDE.md invariant 2). A view is\nregistered in three places (CLAUDE.md invariant 3). Follow the steps for the path you need.\n\n## Step 0: pick the render path\n\n| You need…                                                        | Path                                                                                  |\n| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------- |\n| JSON data for the model                                          | Registrar tool in `src\u002Ftools\u002F\u003Cdomain>.ts` (Steps 1–3)                                 |\n| A view for the user                                              | a cockpit route plus its builder and `*_data` feed (Step 4); never a second view tool |\n| App-only JSON for in-widget refresh\u002Fnav                          | `*_data` feed in `DATA_FEEDS` of `src\u002Fdata\u002Ffeeds.ts` (Step 4)                         |\n| An answer the CLI's workflow commands give too (inspect, status) | a composite on `@miragon\u002Fpulse-c7-core` over `src\u002Flib\u002Fcore-adapter\u002F` (Step 2b)        |\n\n## Step 1: input schema next to the tools\n\nAdd the Zod input schema to `packages\u002Fconnectors\u002Fengine\u002Fengine-connector\u002Fsrc\u002Fschemas\u002F\u003Cdomain>.ts` and\nexport it from `src\u002Fschemas\u002Findex.ts`. Existing style (from `schemas\u002Fprocess-instances.ts`):\n\n```ts\nexport const listProcessInstancesInput = z.object({\n  processDefinitionKey: z.string().optional(),\n  businessKeyLike: likeParam(),\n  active: flagParam(),\n  withIncidents: flagParam(\"true = only instances with an open incident\"),\n  firstResult: firstResultParam,\n  maxResults: maxResultsParam(), \u002F\u002F capped at MAX_PAGE_SIZE (100); default page 20\n  sortBy: z.enum([\"instanceId\", \"definitionKey\"]).optional(),\n  sortOrder: sortOrderParam,\n})\n```\n\n**Descriptions.** Describe a field only where the tool gives it its own meaning: what `false`\nor an omitted value selects when no shared rule says it (`withIncidents`: open only), a unit or\nformat the name does not say (`retryTimeout`: in milliseconds), an id that names another object\nthan its name suggests, or which views take it (`engine_open_cockpit`). A description that\nrestates the name or the tool description goes. What a parameter means on every tool\n(`engine`, paging and sorting, `processDefinitionKey`\u002F`processDefinitionId`, `activityId`,\n`incidentType`, `…Like`, the boolean filters, the date bounds, the variable shape) is said once\nin `engineInstructions` (`packages\u002Fconnectors\u002Fengine\u002Fengine-connector\u002Fsrc\u002Finstructions.ts`),\nand the paging, sorting, `…Like`, flag, date and variable-map helpers in `schemas\u002Fshared.ts`\ncarry no description of their own, so a field built from them needs none (`flagParam` and\n`engineDateParam` take one where the field means more; `wholeVariableParam` keeps its own). A\nnew shared meaning goes into the instructions, briefly, not onto each tool. A safety rule stays\nat the write it guards (the cut-value rule on the variable writes), where a host that drops the\nserver instructions still shows it. `apps\u002Fmcp-server\u002Ftest\u002Ftools-list.golden.test.ts`\nfails when one description repeats on more than two model-visible tools.\n\nA list\u002Fquery tool pages with the shared params from\n`schemas\u002Fshared.ts` (`firstResultParam` + `maxResultsParam(defaultPageSize)`), never a\nhand-rolled `maxResults` (a `*_data` feed spreads `pagingShape` from the same file, the one paging\ndefinition in its described, default-free form): `src\u002Ftools\u002Flist-tools.test.ts` treats every registrar tool that\ntakes `maxResults` as a list and fails it without the cap or the envelope (Step 2). The\nREST calls themselves use the generated SDK (imported from\n`@miragon\u002Fpulse-c7-client\u002Fsdk`). If the endpoint is missing there, the OpenAPI spec\nchanged and you need `pnpm generate`, not a hand-written fetch. The engine's REST rules the\nhandler applies (`toEngineDate`, `engineLike`, `complementaryFlags`, `engineSorting`,\n`toEngineVariables`, `incidentRecovery`, `RAW_VARIABLES`, `isMissing`) come from\n`@miragon\u002Fpulse-c7-core`, their one home, which the CLI uses too; the reads bound to the SDK\n(`fetchJobStacktrace`, `readProcessInstanceVariables`, `readBatch`) from\n`@miragon\u002Fpulse-c7-client`. Those reads throw every 404: a handler that reads a missing\nresource as \"none\" wraps the read in `noneIfMissing` (`src\u002Flib\u002Fcore-adapter\u002Fengine-port.ts`,\nc7-core's `isMissing`), so a wrong REST root still fails loud.\n\n### Parameter naming guide\n\nInputs are STRICT (the registrar is always strict; raw `server.registerTool()` registrations use\n`strictToolInput(...)` from `@miragon\u002Fpulse-widget-shell\u002Fserver`, and the boot refuses any other\ninput schema that lets an unknown key through): an unknown key is a tool error listing the valid keys. So one name per concept across every tool: a model that learned\n`assignee` on one tool must not be refused it on the next. Renames ship without aliases.\n\n| Concept                  | Name                                                                                                                                                                                                                                                   |\n| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| Process definition       | `processDefinitionKey` (all versions), `processDefinitionId` (one version); never bare `key`                                                                                                                                                           |\n| Entity ids               | `\u003Centity>Id`: `processInstanceId`, `deploymentId`, `jobId`, `activityId`; never bare `id`, never `elementId`                                                                                                                                           |\n| Several values           | `\u003Cname>In`, an array (`processDefinitionKeyIn`); merged with the single form via `engineKeyList`                                                                                                                                                       |\n| User filter              | `assignee` (the user a task is assigned to); a write that sets a user takes `userId`                                                                                                                                                                   |\n| Business key \u002F substring | `businessKey` exact, `businessKeyLike`\u002F`nameLike` substring via `likeParam()`: `engineLike` wraps a `%`-less value                                                                                                                                     |\n| Time bounds              | `\u003Cevent>After`\u002F`\u003Cevent>Before` (`startedAfter`, `finishedBefore`, `incidentTimestampAfter`) via `engineDateParam()`: ISO 8601 in, `toEngineDate` out                                                                                                   |\n| Boolean filters          | `flagParam()` + `complementaryFlags` (active\u002Fsuspended, …) or `trueOnly`: the engine IGNORES `false` (never sent); a both\u002Fneither pair is refused; a new `complementaryFlags` pair is named in `engineInstructions` (`instructions.test.ts` checks it) |\n| Paging \u002F result size     | `firstResult` + `maxResults` from `firstResultParam` + `maxResultsParam` (above; also for a top-N cap: never `limit`)                                                                                                                                  |\n| Sorting                  | `sortBy` + `sortOrder`, sent as a pair by `engineSorting`                                                                                                                                                                                              |\n\nThe engine's own query names may differ (`taskAssignee`, `processInstanceBusinessKey`,\n`withIncident`): the handler maps them, the schema keeps the guide's name. Every\nfilter a tool advertises is pinned on the wire in `src\u002Ftools\u002Ffilters.wire.test.ts`\n(page query AND `\u002Fcount`); a retired spelling (`key`, `id`, `limit`, `elementId`, …) on\nany module tool fails `apps\u002Fmcp-server\u002Ftest\u002Fstrict-input.e2e.test.ts`, so a parameter that\ninstructions or a description quote but the tool does not take turns into a refused call.\nThe one quoted filter set in the instructions (the retry playbook's `engine_list_jobs`\nfilters) is held to `listJobsInput` by `src\u002Finstructions.test.ts`; a newly quoted parameter\ngets its own pin there. Widget Ask-AI hand-offs and model contexts never quote parameters:\nthey pass typed `ids` (see Step 4).\n\n## Step 2: register the tool\n\nAdd the tool in `packages\u002Fconnectors\u002Fengine\u002Fengine-connector\u002Fsrc\u002Ftools\u002F\u003Cdomain>.ts`. The reference template is\n`engine_list_process_instances` from `src\u002Ftools\u002Fprocess-instances.ts`:\n\n```ts\nimport { listProcessInstancesInput } from \"..\u002Fschemas\u002Findex.js\"\nimport type { ToolRegistrar } from \"@miragon\u002Fpulse-widget-shell\u002Fserver\"\nimport { complementaryFlags, engineLike, engineSorting } from \"@miragon\u002Fpulse-c7-core\"\nimport { getProcessInstances, getProcessInstancesCount } from \"@miragon\u002Fpulse-c7-client\u002Fsdk\"\nimport { paginatedListOutput, toPaginatedList } from \"..\u002Flib\u002Fmcp\u002Fpagination.js\"\nimport type { EngineRegistry } from \"..\u002Flib\u002Fengine\u002Fresolve-engine.js\"\nimport { engineParamShape, withEngine } from \"..\u002Flib\u002Fengine\u002Fwith-engine.js\"\n\nexport function registerProcessInstanceTools(register: ToolRegistrar\u003CEngineRegistry>) {\n  register({\n    name: \"engine_list_process_instances\",\n    category: \"process-instances\",\n    description: \"List running process instances.\",\n    annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },\n    inputSchema: { ...listProcessInstancesInput.shape, ...engineParamShape },\n    outputSchema: paginatedListOutput,\n    handler: withEngine(async (client, args) => {\n      \u002F\u002F The filters in the engine's format (naming guide), shared by the page\n      \u002F\u002F and its \u002Fcount twin (fetched in parallel), so the envelope's total is honest.\n      const filters = {\n        processDefinitionKey: args.processDefinitionKey,\n        businessKeyLike: engineLike(args.businessKeyLike),\n        ...complementaryFlags(args, \"active\", \"suspended\"),\n      }\n      const [items, count] = await Promise.all([\n        getProcessInstances({\n          client,\n          query: {\n            ...filters,\n            firstResult: args.firstResult,\n            maxResults: args.maxResults,\n            ...engineSorting(args),\n          },\n        }),\n        getProcessInstancesCount({ client, query: filters }),\n      ])\n      return toPaginatedList(items, count, args.firstResult)\n    }),\n  })\n}\n```\n\nA list tool's description says what it lists, never the paging contract\n(`src\u002Ftools\u002Flist-tools.test.ts`).\n\nNon-negotiables:\n\n- Every tool carries a `category` matching its domain file name (e.g.\n  `\"process-instances\"`, `\"tasks\"`; `task-form.ts` uses `\"tasks\"`,\n  `incident-issue.ts` uses `\"incidents\"`).\n- `inputSchema` spreads `...engineParamShape` so every tool accepts the per-call\n  `engine` override.\n- `handler` is wrapped in `withEngine(...)`. Never construct, cache, or import a client\n  directly; `withEngine` resolves the engine from the registry per call.\n- Tool names are `engine_\u003Cverb>_\u003Cnoun>` in snake_case.\n- Write tools that only flip state return a small `{ success: true, … }` object instead\n  of the raw (often empty) REST response.\n- Destructive\u002Fadmin-grade tools (delete, modify, suspension, deployments, migrations,\n  batches, engine-wide broadcasts like signals, the external-task worker protocol) and\n  reads that only feed an admin write (`engine_get_batch`,\n  `engine_get_activity_instance_tree`) must\n  be added to `ADMIN_ONLY_TOOLS` in `src\u002Flib\u002Fmcp\u002Ftoolsets.ts` so the\n  `engine:read-only|operations|admin` toolset filtering stays correct. `read-only`\n  membership is derived from `readOnlyHint: true`. `src\u002Flib\u002Fmcp\u002Ftoolsets.test.ts` enforces\n  the rule structurally over every registrar tool (`destructiveHint` ⇒ admin-only,\n  read-only ⇒ `readOnlyHint`, every write states `destructiveHint` explicitly), and\n  `src\u002Fplugin.test.ts` over every tool the module registers, feeds included (the\n  read-only floor holds only `readOnlyHint` tools); get the annotations right, never\n  edit the test to pass.\n- Toolsets fail CLOSED: with no suffix a deployment runs `read-only` (no OAuth) or\n  `operations` (OAuth); `admin` is only ever reached by naming it. So an `operations`\n  tool is on for every authenticated default deployment and an `admin` tool for none:\n  place a new write deliberately. A tool that runs code inside the engine\n  (`engine_create_deployment`) additionally sits behind its own strict env opt-in\n  (`ENGINE_ALLOW_DEPLOYMENTS=true`) and is not registered without it; follow that\n  pattern for anything comparable instead of trusting the toolset alone.\n\n### Annotation conventions\n\n| Operation                                     | Annotations                                                             |\n| --------------------------------------------- | ----------------------------------------------------------------------- |\n| Read \u002F list \u002F get                             | `{ readOnlyHint: true, idempotentHint: true, openWorldHint: true }`     |\n| Set-style write (variable, retries, assignee) | `{ destructiveHint: false, idempotentHint: true, openWorldHint: true }` |\n| Other write (start, complete, correlate, …)   | `{ destructiveHint: false, openWorldHint: true }`                       |\n| Delete \u002F irreversible (delete, modify)        | `{ destructiveHint: true, openWorldHint: true }` (admin-only)           |\n| Engine-wide \u002F worker \u002F deploy                 | `{ destructiveHint: true, openWorldHint: true }` (admin-only)           |\n\nMCP reads an ABSENT `destructiveHint` as `true`, so every write states it; `false` is a\ndeliberate claim, not a default. `idempotentHint: true` marks writes that repeat to the\nsame state. Every engine tool carries `openWorldHint: true` (it talks to an external\nengine); a tool that only reads the server's own config (`engine_list_engines`) says\n`openWorldHint: false`.\n\n## Step 2b: a read composite on c7-core\n\nA question the CLI answers too (where an instance waits, how an engine is doing) runs on\n`@miragon\u002Fpulse-c7-core` (`packages\u002Fconnectors\u002Fc7\u002Fc7-core`, the core of the Camunda 7 REST\ndialect family c7, which the engine module spans), the workflow domain both share, never on a\nsecond copy here. A rule both need that it\nlacks goes into that package (a leaf: no dependency, data rather than wording).\nThe pattern is `engine_inspect_process_instance` and `engine_status`\n(`src\u002Flib\u002Fcore-adapter\u002Fcomposites.ts`, registered like any registrar tool):\n\n- Map nothing by hand: the port (`src\u002Flib\u002Fcore-adapter\u002Fengine-port.ts`) sends every operation\n  the composite asks for as its route of c7-core's `CORE_ROUTES` (`routedTransport`) through the\n  engine's client, so a composite that needs an operation c7-core does not send yet adds it to\n  c7-core's `CORE_OPERATIONS`, and `src\u002Flib\u002Fcore-adapter\u002Fengine-port.test.ts` holds every\n  operation of c7-core's lists to a reading route.\n- Word c7-core's error reasons in `src\u002Flib\u002Fcore-adapter\u002Fworkflow-errors.ts` (`[CODE] message` in the\n  tool's parameter names, no tool names).\n- Return data for the model: drop `next` (it would name tools a toolset may lack), cut\n  variables with `truncatePlainVariables`, and cap every list c7-core leaves uncapped for\n  the CLI (today wait states, incidents, timeline, variables, definitions, topics), naming each\n  cut in `truncated`. The cap bounds the result, not the reads. Pin the wire and each cap in\n  `src\u002Flib\u002Fcore-adapter\u002Fcomposites.wire.test.ts` (inspect) and\n  `src\u002Flib\u002Fcore-adapter\u002Fcomposites-status.wire.test.ts` (status), over the helpers of\n  `src\u002Flib\u002Fcore-adapter\u002Fcomposites.test-support.ts`.\n- How an engine is doing has one answer: mask incidents with c7-core's `messageSignature`,\n  count one cause with its `causeKeyOf`\u002F`largestCause` and judge with its `engineVerdict`\n  (`ok | warning | critical`) by the `ENGINE_HEALTH_*` thresholds (`pulse status`: the\n  defaults), as `engine_status` and the cockpit's health route (`src\u002Fdata\u002Fhealth-data.ts`)\n  do. Neither text names the other's verdict.\n\n`src\u002Flib\u002F` is split by job, and each helper goes into the folder of its job: `engine\u002F`\n(routing: `resolveEngine`, `withEngine`, the cockpit links, passthrough auth), `core-adapter\u002F`\n(the c7-core port, the composites, their error wording), `bpmn\u002F` (the definition XML,\nBPMN and task-form reading, completing a user task by its form), `mcp\u002F` (the tool surface:\ntoolsets, the paging envelope, the bounds on variable values and failure text) and `shared\u002F`\n(pure code the widgets run too). `src\u002Fdata\u002F` and `src\u002Flib\u002F` never import `src\u002Ftools\u002F`,\n`src\u002Fwidget-tools\u002F`, `src\u002Fwidgets\u002F` or the connector's root (`index`, `plugin`, `module`)\n(`connector-data-and-lib-point-down`): a helper a builder needs from a tool moves down into the\nlib folder of its job (the task form's `buildTaskFormSchema` into `bpmn\u002F`).\n\n## Step 3: wire a new domain file\n\nOnly when you created a new `src\u002Ftools\u002F\u003Cdomain>.ts`: add\n`registerYourDomainTools(register)` to `registerTools` in\n`packages\u002Fconnectors\u002Fengine\u002Fengine-connector\u002Fsrc\u002Ftools\u002Findex.ts`, mirroring the existing calls.\n\n## Step 4: widget path (only for UI features)\n\nWidget components live in `packages\u002Fconnectors\u002Fengine\u002Fengine-connector\u002Fsrc\u002Fwidgets\u002F` and receive their data as\na `data` prop. Compose them from the design kit `@miragon\u002Fpulse-ui` and the view runtime\n`@miragon\u002Fpulse-widget-shell\u002Fwidgets` (pulse-ui holds what touches no host: primitives,\ncomponents, tone maps, `cn`, the formatters, `Icon`, the table cells; widget-shell holds what\nreads the host or calls a tool, and neither re-exports the other's names); never re-inline\nthese primitives. Widgets are browser code: at runtime they import only their own widgets,\n`src\u002Fmessages`, `src\u002Ftool-names.ts`, the view models, `@miragon\u002Fpulse-widget-shell\u002Fwidgets` and the\nview stack, plus `src\u002Flib\u002Fshared` (pure code the server runs too, held to the same allowlist);\n`src\u002Ftools`, `src\u002Fdata`, the feed contracts, the plugin, the module,\n`@miragon\u002Fpulse-c7-client` and anything else only as types (`import type`), and the other\n`src\u002Flib` folders not even as types: a type both sides need is declared once in\n`src\u002Flib\u002Fshared` (`TaskCompletion`). Data arrives through the view tool and the `*_data` feeds\n(`connector-widgets-are-browser-code` and `connector-widgets-only-shared-lib` in\n`.dependency-cruiser.cjs`):\n\n- `ViewDataState` is the loading\u002Ferror\u002Fno-data guard (no inline Alert\u002Floading ternary):\n  `onRetry` is required (`paged.retry`, a query's `refetch`), though a refusal no retry\n  changes (`isPermanentError`: a refused profile, a read-only engine) shows no Try again, and\n  the view's query client never retries one; loading is a `skeleton`\n  slot under a polite sr-only status (\"Ansicht wird geladen…\" unless `loadingText`),\n  no data pulse-ui's `EmptyState`\n- Self-fetching widgets: skeleton + error via `QueryFallback` (+ `TableSkeleton`). A\n  missing `isError` branch means an eternal skeleton; `onRetry` is required\n- Errors speak through widget-shell's `describeError(error, locale)`: an engine or transport\n  failure (503\u002Funreachable, timeout, a tool this server lacks, 401\u002F403, 404, an engine\n  exception) becomes what happened (\"Die Engine prod ist nicht erreichbar (HTTP 503)\")\n  and what you can do, the raw \"[503 ProcessEngineException] …\" collapsed under the\n  \"Details\" of pulse-ui's `ErrorState`: never raw engine text as a headline or in a toast.\n  `ViewDataState`, `QueryFallback`, `useDetailView` (its stale notice: an inline warning\n  `ErrorState`), `ConfirmDialog`, `useActionFeedback` and the view's own failed call\n  (`ViewFrame`) all use it; a widget's own error line does too\n  (`ErrorState` with `describeError(...)`), and `errorTitle` heads only a failure\n  `describeError` cannot classify\n- A view that reads its feed directly calls widget-shell's `useViewData` (from\n  `@miragon\u002Fpulse-widget-shell\u002Fwidgets`): the handed-in tool result always seeds a live\n  query, so Refresh and a write's invalidation re-read it (#341). Widgets import every\n  query hook (`useToolQuery`, `useToolQueries`, `useToolMutation`, `useViewData`,\n  `useQueryClient`) and the host seam (`useViewHost`, `HostModelContext`) from the view\n  runtime, never from `@modelcontextprotocol\u002Fext-apps` (ESLint); tests render them under\n  `TestViewHost`, whose `locale` (else an outer `LocaleProvider`'s, else English) and\n  `host.timeZone` the formatters render in, as `LocaleGate` publishes the host's: a widget test\n  sets no `setFormatLocale` of its own and reads the same on every machine\n- Across engines (the cross-engine overview) a widget reads each engine's feed under the key\n  its other readers use (`useToolQueries`, one read per engine), and a figure over engines is\n  a sum of counts (`foldFleetLandscape` in `src\u002Flib\u002Fshared\u002Ffleet-landscape.ts`), never a\n  rate, a duration or a ranking: engines run different processes\n- `formatTimestamp`\u002F`formatDate`\u002F`formatTime`\u002F`formatClock`\u002F`formatInstant`\u002F\n  `formatRelativeTime` (dates) and `formatNumber`\u002F`formatCount`\u002F`formatCompactCount`\u002F\n  `formatPercent`\u002F`formatPercentPoints`\u002F`formatPeriod`\u002F`formatDuration`\u002F`shortId`\u002F`truncate`,\n  all from `@miragon\u002Fpulse-ui` (the same set the VS Code extension reads from\n  `@miragon\u002Fpulse-ui\u002Fformat`): no local format\n  helpers, no `toFixed`, no `toLocaleString()` without a locale, no hand-built\n  `Intl.NumberFormat` (canonical duration style\n  \"3m 7s\", \"2d 3h\" \u002F German \"3 Min. 7 s\"; a lower bound \"≥ 2,000\", compact \"10k+\";\n  \"3,7 %\", \"+0,2 Pp.\", \"7 Tage\")\n- Tables: widget-shell's `ListTable` is the frame: a named, keyboard-reachable scroll region\n  (`role=\"region\"`, `tabIndex=0`; `ariaLabel` is required), so a wide table never widens\n  the document at 760 px (the host simulation pins it). A column a narrow view can do\n  without takes `hideBelow: \"sm\" | \"md\" | \"lg\"` (the REGION's width: 640\u002F768\u002F1024 px;\n  `md` hides inline, shows in fullscreen), the row actions are the LAST column with\n  `sticky: true` (pinned at the region's right edge, Retry never scrolled away). Rows\n  stay hand-composed `\u003Ctr>` + `Td`, one cell per column (the rules address cell\n  positions). An id column is `IdCell` (`IdText` inline): Geist Mono, shortened\n  (\"5f0ce78e…\", whole for screen readers and in `title`), a copy button with a toast.\n  Nothing to show: pulse-ui's `TableEmptyState`, `variant=\"done\"` (`CircleCheck`, success) for\n  \"nothing left to do\" (\"Keine offenen Incidents\"), the default `empty` (quiet `Inbox`)\n  for no data; `description`, `action`. The Komet (`KometMark`, `AppEmptyState`) only on\n  APP-level surfaces (landing, first start, \"no engines\"), never in a widget\n- Type roles (pulse-ui's `TYPE_SCALE`): a widget's page title is the `WidgetHeader`\n  (28\u002F700, an h2 below the view, `as`), a section `text-section` (21\u002F600), running text\n  `text-body` (16), dense tables and controls `text-dense` (14), captions and meta\n  `text-small` (13, the floor); no `text-xs`\u002F`text-[11px]`, no Tailwind sizes in kit\n  code (`scanTypeScale` from `@miragon\u002Fpulse-ui\u002Ftesting`; connectors adopt it with `rules:\n[\"below-floor\"]` first)\n- From pulse-ui: `Section` (collapsibles); `Th`\u002F`Td` (cells);\n  `WidgetHeader` (`badge`\u002F`titleSuffix`\u002F`size=\"detail\"`\u002F`as`, no icon tile) + `VersionChip`\n  (hero\u002Fdetail headers); `KpiGrid` (KPI strips, incl. `variant=\"soft\"`; a\n  clickable cell says what the click does in `actionHint`, never `ariaLabel`). From the view\n  runtime: `WidgetShell` (page container, split into View + Shell only when the body is\n  embedded elsewhere)\n- Where and what (#322 U4, U13): the cockpit's head names the engine and its environment (a\n  text chip, \"PROD\"), so a route view's `WidgetHeader` `sub` carries only the time it read\n  (widget-shell's `ViewMeta` with `asOf`) and its own facts; a route widget takes `engine` and\n  its ids as required props and builds its feed args from them alone. A write confirmation\n  adds `engineTargetLines(t, engineId, useEngineEnvironment(engineId))`\n  (`widgets\u002Flib\u002Fengine-context.tsx`). Names before ids: the\n  design kit's `NamedId` (`@miragon\u002Fpulse-ui`: an activity's or process's name, the\n  id a quiet mono line; the same look in every module), `InstanceLabel`\n  (`widgets\u002Flib\u002Fidentity.tsx`: the business key, the UUID a copyable `IdText` chip; the\n  words of `instanceName`); a drill passes the target's `label` so its breadcrumb reads\n  the name. The builder reads names as enrichment (`fetchNamesByDefinition` with\n  `processNameIn`\u002F`elementNameIn`, `fetchDefinitionsById`, `fetchBusinessKeys`), every\n  diagram name through widget-shell's `readBpmnNames`\u002F`parseBpmnNames` (never a regex of your\n  own: every view reads the same diagrams), and a view that shows the engine's definition\n  name passes its rows through `nameUnnamedDefinitions` (a Modeler pool around an unnamed\n  process names it, as in every view). A row keeps ONE deterministic action;\n  its vendor link and chat hand-offs sit behind `MoreActions`, as `variant=\"menuitem\"`\n  entries (`HandOffButton`, `OpenInCockpitLink`); a confirmation names an instance with\n  `instanceTargetLines` (business key, then short id)\n- Status: a `ToneVariant` (`danger`\u002F`warning`\u002F`success`\u002F`info`\u002F`neutral`; open incident or\n  failed job without retries = `danger` (`severityTone`\u002F`countTone` from `@miragon\u002Fpulse-ui` is\n  the one ladder for incident and job counts in every module, a count without a state stays\n  neutral; an instance's or a step's state takes its tone from `SITUATION_TONES`, the table the\n  VS Code webview reads too: running `info`, suspended and ended `neutral`), retries left or\n  degraded = `warning`; an\n  engine's own state is its verdict's tone, `VERDICT_TONE`, never a ladder over its counts)\n  shown via pulse-ui's `StatusBadge` (tone and words required)\u002F`CountPill`\u002F`KpiCell.tone` or its\n  `TONE_DOT`\u002F`TONE_BORDER`\u002F`TONE_ICON`\u002F`TONE_TINT`\u002F`TONE_SOFT`\u002F`TONE_INK` maps (a tone\n  from the wire through `resolveTone`), next to readable words; never a coloured\n  number, never a palette class or raw colour; text on a tint is black (`TONE_SOFT`), the\n  `-ink` role is only for text in a tone directly on the card or page (an inline error)\n- Icons: Lucide via `Icon` (`import { FileSearch } from \"lucide-react\"`; the package pins\n  devDependency `lucide-react` `1.70.0`, see CLAUDE.md invariant 6), never a Unicode glyph\n  or emoji, at 16 px (`dense` in rows; `scanIconSizes` allows 16 and 24 only); a chat\n  hand-off is widget-shell's `\u003CHandOffButton action=\"explainError\" prompt={ask(…)} \u002F>`\n  (`@miragon\u002Fpulse-widget-shell\u002Fwidgets`: the product's one table of functions, each with its\n  Lucide icon and a label that names the chat, \"Fehler im Chat erklären\" \u002F \"Explain error in\n  chat\", the same in every module; `AskAiButton` is banned in connector widgets, so a new\n  function gets its entry and both labels in widget-shell), placed after the deterministic action; `OpenInCockpitLink` gets `vendor={data.engineVendor}` (the\n  builder sets it from `provider.branding.displayName`)\n- Copy: en + de catalogs in the brand voice (du, AI, \"CIB seven\", no dash connector,\n  errors say what happened and what you can do, `Ja`\u002F`Nein` for Booleans) and the terms of\n  `src\u002Fmessages\u002FGLOSSARY.md` (a count the VS Code webview shows too takes its word from\n  `COUNT_TERMS`); counts go in formatted (`formatNumber`) and the entry picks\n  the noun's number (`countOf` from `messages\u002Fplural.ts`). The gates: `catalogTextFindings`\n  over every catalog and en\u002Fde key parity of the widget catalogs (`messages\u002Fcatalog-text.test.ts`),\n  the glossary\n  (`messages\u002Fglossary.test.ts`), `scanGlyphs` over all of `src` and `scanColors` over\n  `src\u002Fwidgets` (`src\u002Fbrand-gates.test.ts`; the gates, the glossary and the count terms come\n  from `@miragon\u002Fpulse-ui\u002Ftesting`)\n- A pair: the overview, a process, its instances, an instance, an incident and the history\n  have a counterpart in the VS Code webview, which shares only pulse-ui's foundation with the\n  widgets. A field, a count or an action you add to one says in its pull request whether the\n  webview gets it and updates `docs\u002Fvscode\u002Fparity.md`; its content takes props and callbacks\n  only, no host context (as `process-definitions-table-view.tsx` with `renderActions`)\n- BPMN: viewer lifecycle and the fixed light canvas via `useBpmnViewer` (the container\n  is a forced-light island, `data-theme-mode=\"light\"`, on the paper token) +\n  `BpmnZoomControls` (light in every theme, Lucide). The frame height comes from the\n  diagram's size (`height` from the hook: its height at the scale bpmn-js draws it, never\n  past 1:1, 240–560 px, read from the XML's DI before the import, so the frame does not\n  jump): pass no fixed `height` to `BpmnDiagram` unless a layout needs one. The process flow\n  draws only engine reads: the open incidents, or as \"Running\" the `instance-count`,\n  `incident` and `failed-jobs` badges of `engine_activity_statistics_data` (every version\n  summed onto the latest diagram, mounted only once picked), or as \"Throughput\" its history\n  source as heat (`BpmnHeatmap`, the ramp `BPMN_TOKENS.heat` read from the theme's variables;\n  a read that loads the engine's database: see CLAUDE.md invariant 7).\n  Overlay and legend colours are widget-shell's ONE token module (`BPMN_TOKENS`,\n  `widget-shell\u002Fsrc\u002Fui\u002Fbpmn-tokens.ts`: incident = danger, running\u002Fopen task = blue on the\n  info tint, counts white on blue or danger; strokes ≥ 3:1, badge text ≥ 4.5:1, drift- and contrast-tested) through `HIGHLIGHT_COLORS`\n  (`src\u002Fwidgets\u002Fbpmn-highlights.ts`); the CSS is\n  `injectBpmnOverlayCss()`. No pulse: an open user task is a heavier dashed stroke\n\n**Paged list recipe** (every list that can exceed one page follows it; reference:\n`src\u002Fwidgets\u002Fprocess-instances\u002Flist.tsx`):\n\n1. Feed: accept `firstResult`\u002F`maxResults` (+ server-side filter params like\n   `businessKeyLike`\u002F`nameLike`) and return the full filtered total (via the matching\n   `\u002Fcount` endpoint) so the footer is honest.\n2. Widget: `usePagedListView` (owns search state, 300 ms debounce, the server-side\n   `searchArg`, and the drop-initialData-on-interaction rule; chips go into\n   `filtersActive` + the `args`) → `FilterBar` (with `searchAriaLabel`, the placeholder's\n   words without the ellipsis, and `chipsAriaLabel` for a chip row: pulse-ui's default\n   name is a generic \"Filter\"; `src\u002Fwidgets\u002Fsearch-labels.test.tsx` pins every search box\n   by its name) → `PagedRows` (marks the rows while they\n   are the previous result) around `ListTable` (frame + `Th` header row;\n   rows stay hand-composed `\u003Ctr>` + `Td`) or a `RowCard` stack → `TableEmptyState`\n   (distinguish \"no match\" when `interacted`) → pulse-ui's `PagedListFooter`\n   (`paged` as is, `noun` from the catalog; it renders the stale page-0 error and the load-more\n   error, each with its own retry and the raw reason under Details, + \"Showing X of Y\" in\n   the list's one live region + Load more; deliberately not infinite scroll; rendered\n   under an empty result too). Guard the whole view on\n   `!paged.firstPage` only: a search keeps the previous rows (and the search box)\n   mounted while its page 0 is in flight. While it does, `paged.stale` is true and\n   `firstPage`\u002F`total` are the PREVIOUS result's: a header count, the filters it\n   covers and the model context read the payload's echo (`data.filters`), never the\n   request's args (reference: the instance list's `listFilters`).\n\nThe engine module has ONE view, `engine_open_cockpit` → `CockpitApp` (CLAUDE.md invariant 3). New\nUI is a **cockpit route**, never a second view tool. On the server a route is five entries;\n`COCKPIT_ROUTES` and the widgets' route switch fail to compile without it, and\n`src\u002Fdata\u002Fcockpit-start.test.ts` fails on a route whose feed is missing from `DATA_FEEDS`:\n\n1. `src\u002Fview-models.ts`: a section in `CockpitView` with the ids the route reads, and its\n   data type in `CockpitStartData`.\n2. `src\u002Fdata\u002F`: ONE builder for the route's data (`\u003Croute>-data.ts`).\n3. `src\u002Fdata\u002Ffeeds.ts`: the route's `*_data` feed,\n   `dataFeed({ name, title, description, shape, read })`, in `DATA_FEEDS`; `read` calls the\n   builder. `src\u002Fwidget-tools\u002Fdata-feeds.ts` registers every feed of `DATA_FEEDS`.\n4. `src\u002Ftool-names.ts`: the feed's name as an `ENGINE_*_DATA` constant.\n5. `src\u002Fdata\u002Froutes.ts`: the route's entry in `COCKPIT_ROUTES`: the ids it `needs` and also\n   `takes` (fields of its own `CockpitView` section, checked at compile time), its `start` (its\n   feed's read with the first page's args) and its `facts`. `src\u002Fwidget-tools\u002Fcockpit.ts`\n   builds the `view` enum and checks the ids from it, and `src\u002Fdata\u002Fcockpit-start.ts`\n   dispatches the start view and its facts. A start param new to the cockpit also gets its\n   described field in `startViewShape` (`src\u002Fwidget-tools\u002Fcockpit.ts`; `StartParam` is read\n   off `CockpitView`, so a missing field fails to compile), and a route that takes an existing\n   param adds its name to that param's description (the tools\u002Flist golden shows the change).\n\nIn the widgets a route is its widget, a case in `CockpitRoute`\n(`src\u002Fwidgets\u002Fcockpit-app\u002Froutes.tsx`) that renders it with `seedOf(seed, section)` as its\n`data` and the route's `engine` and ids as required props, a `NavIntent` and an emitter (below)\nand its en\u002Fde messages. A composed view (a dashboard, the process detail) is ONE component that\ncomposes its widgets in a `ViewStack`.\n\nThe start data is exactly the feed's page-0 payload: build it with the size the widget\npages by (`LIST_PAGE_SIZE`, `HISTORY_PAGE_SIZE` in `src\u002Fview-models.ts`), so the route's\nwidget seeds its feed with it and refreshes after a write without a fetch on mount\n(`src\u002Fdata\u002Fcockpit-start.test.ts` pins the seed against the feed). The facts are the\nmodel-facing result: compact JSON, English keys, ids and counts (a `null` count is\nunknown, never 0), engine text clipped, never XML. A route needs an emitter in the cockpit\ntoo: a `DrillButton`, a KPI cell's `onClick`, or a head section in\n`src\u002Fwidgets\u002Fcockpit-app\u002Fapp.tsx`, each through `useNav()` with a `NavIntent`\n(`src\u002Fwidgets\u002Fnavigation.ts`) that `intentToView` (`src\u002Fwidgets\u002Fnav-core.ts`) maps onto the\nroute. A route that nothing emits and `engine_open_cockpit` cannot open is dead.\n\n`apps\u002Fmcp-server\u002Ftest\u002Fview-registry.test.ts` holds the view registry\n(`apps\u002Fmcp-server\u002Fsrc\u002Fui\u002Fview-registry.ts`, which spreads `engineViews` from\n`src\u002Fwidgets\u002Findex.ts`) to the view-bound tools of the tools\u002Flist goldens.\n\n`src\u002Fwidget-tools.ts` wires the cockpit tool (`src\u002Fwidget-tools\u002Fcockpit.ts`), the `*_data`\nfeeds (`data-feeds.ts`, over `DATA_FEEDS`) and the actions feed; these files are the documented\nexception that calls the official SDK's `server.registerTool(name, config, handler)` directly\n(`server` is the module's `ModuleServer`; its handler gets `(args, ctx)`, the SDK's context with the\ncall's abort signal at `ctx.mcpReq.signal`):\n\n- Every widget-path tool (the cockpit AND each feed) declares\n  `inputSchema: strictToolInput({ … })` (from `@miragon\u002Fpulse-widget-shell\u002Fserver`), never\n  a bare `z.object`, which strips an unknown key silently;\n  `apps\u002Fmcp-server\u002Ftest\u002Fstrict-input.e2e.test.ts` refuses any module tool that\n  accepts one.\n- `engine_open_cockpit` spreads `...showToolBinding(ENGINE_OPEN_COCKPIT, \"Open Cockpit\")`\n  (from `@miragon\u002Fpulse-widget-shell\u002Fserver`) into its config, which binds the view named after\n  the tool (`_meta.ui.resourceUri: \"ui:\u002F\u002Fviews\u002F\u003Ctool>.html\"`, whose resource the boot serves),\n  adds the required passthrough `outputSchema` and stamps the Apps-SDK half of `_meta`;\n  **never write `_meta` keys by hand**. Resolve the engine via\n  `resolveEngine(args.engine, registry, ctx)`, which already returns `baseUrl`\u002F`cockpitUrl`;\n  never fish them out of `registry.engines`. It returns `buildViewResult(data, facts)`\n  (from `@miragon\u002Fpulse-widget-shell\u002Fserver`): `data` IS the view's data, no title (the\n  widget's heading names the view); the facts are the compact JSON of the route's `facts`, the\n  model-facing text channel, while the full payload travels only in `structuredContent`.\n- `*_data` feeds: describe one with `dataFeed({ name, title, description, shape, read })` in\n  `DATA_FEEDS` (`src\u002Fdata\u002Ffeeds.ts`); `registerDataFeed` in `data-feeds.ts` registers each and\n  spreads `...appOnly` (SEP-1865 `_meta.ui.visibility: [\"app\"]` plus\n  `openai\u002FwidgetAccessible`; **no** view binding) and returns `buildDataFeedResult(data)` so\n  the in-widget `callTool()` gets JSON back instead of the host rendering a new widget.\n  Wrap every raw handler in `withToolErrors` (from `@miragon\u002Fpulse-widget-shell\u002Fserver`).\n- Shared data paths: every number a view shows comes from ONE builder in `src\u002Fdata\u002F`\n  that its `*_data` feed AND the cockpit's start view (`COCKPIT_ROUTES`) call; never\n  fork one. Definition lookups (id parsers, the key lookup, the per-KEY fold of the\n  per-version statistics) come from `src\u002Fdata\u002Fdefinition-info.ts`. A key's latest version is\n  `fetchLatestDefinition`\n  (every tenant), never `\u002Fprocess-definition\u002Fkey\u002F{key}`, which only knows tenant-less\n  definitions. A long key's (over ~25 characters) definition id is a bare generated\n  UUID that names no key, so ANY key that scopes a query, a drill, a hand-off or a\n  cockpit link is resolved, never parsed: a view queried by key uses that key; rows\n  that carry only a definition id go through `resolveDefinitionKeys` (at most ONE\n  definition-statistics read, none when every id names its key): a primary read where\n  the key is what the view hands on, else wrapped in `optional(...)` as enrichment, so\n  a bare id's key is `null` (no drill, no link), never its UUID. A view that already\n  reads the statistics resolves through `definitionKeyResolver` over them.\n  `processDefinitionKeyFromId` is a display fallback only.\n- **Honest numbers** (`src\u002Fdata\u002Fengine-reads.ts`): PRIMARY rows and counts (what the\n  view exists to show, and every number its facts report) propagate engine\n  failures (read them through `countOf`\u002F`rowsOf`; `withToolErrors` turns the throw into\n  a tool error), and an unknown id or key is a not-found error, never a view of zeros. Only\n  ENRICHMENT (display names, diagram XML, cockpit links, optional history) degrades, via\n  `optional(...)`, and to `null`, never to a `0`, `[]` or `{}` that reads as a fact.\n  Widgets render that null as \"—\" (`formatCount` from `@miragon\u002Fpulse-ui`),\n  the facts keep it `null` (the instructions read it as unknown). Totals come from `\u002Fcount`\n  endpoints or statistics, never from\n  the length of a capped page or scan: a recency scan (`INCIDENT_SCAN_LIMIT`, the health\n  views' `CLUSTER_SCAN_LIMIT`) only enriches and says how far it reaches\n  (`scannedIncidentCount`, null facts, \"≥ N\" via `formatCount(exact ?? { atLeast: scanned })`). A view about\n  a process definition KEY spans every deployed version (key-scoped `\u002Fcount`, statistics\n  summed per key); only a diagram is one version, and it is labelled. Engine timestamps\n  compare by instant (`engineDateMillis`\u002F`latestEngineDate`\u002F`earliestEngineDate` from\n  `@miragon\u002Fpulse-c7-core`), never as strings. A new builder adds its case to the\n  rejection table in `src\u002Fdata\u002Fhonest-numbers.test.ts`; the table fails without it. The\n  table breaks EVERY read the healthy build makes (method + path + query names), so a\n  case declares only its enrichment reads; any other read whose failure is swallowed fails.\n- The `show_*`\u002F`*_data` naming is load-bearing:\n  `apps\u002Fmcp-server\u002Ftest\u002Fwidget-contract.e2e.test.ts` enforces the widget `_meta` on\n  every `*_show_*` tool and on `engine_open_cockpit`, and app-only visibility on every\n  `*_data` feed **by name**.\n- A widget-path tool never writes. A write is a registrar tool, so the toolset filter\n  (`withToolsetFilter`\u002F`isToolInToolset`) drops it outside its toolset, and an in-widget\n  write calls it through `useEngineAction`, gated by `engine_widget_actions_data`.\n- Every in-widget write runs through `useEngineAction({ tool, target, resetOn, available })`\n  (`src\u002Fwidgets\u002Flib\u002Fengine-action.ts`), never a raw `useToolMutation`\u002F`callTool`. Its\n  `allowed` is the gate, the toolset AND the subject's current state (`available`:\n  false for an ended, cancelled or unconfirmed instance, a suspended one where the\n  engine refuses the write): render the control only when true (hide it and a table\n  column it would leave empty, never just disable it). List\n  the tool in `ENGINE_WIDGET_ACTIONS` (`src\u002Ftool-names.ts`) and give it a\n  `WRITE_POLICY` entry (`src\u002Fwidgets\u002Flib\u002Fwrite-policy.ts`: the query namespaces it\n  changes, and `confirm: true` for irreversible\u002Fstate-changing writes; then `run`\n  needs a confirmation naming its target, rendered by `EngineActionDialog`, with\n  verb + object on both buttons: widget-shell's `ConfirmDialog`, an `alertdialog` over the\n  scrim, the destructive confirm in the danger role, a failure shown inside it). Every\n  `run` passes `feedback`, the toasts' titles naming the object, from\n  `src\u002Fwidgets\u002Flib\u002Fwrite-feedback.ts` (`writeFeedback.jobRetry(t, instanceName(id,\nbusinessKey))` …; add an entry and its `feedback.*` keys in `messages\u002F{en,de}.feedback.ts`\n  for a new write): a success toast, a failure toast with the reason in words\n  (`useActionFeedback`); a confirmed write's failure stays in its dialog, a read-only\n  engine's refusal at its control, without a toast. Optimistic state reads `action.done` (dropped when\n  `resetOn` changes; pass the data the marked ROWS come from, not a sibling feed).\n  `src\u002Fwidget-actions.test.ts` enforces all of it structurally; add\n  the new call site to `src\u002Fwidgets\u002Faction-gating.sites.ts` + its renderer in\n  `action-gating.test-support.tsx`.\n- Everything the widget tells the model goes through `useHandOff()`\n  (`src\u002Fwidgets\u002Flib\u002Fhand-off.ts`): `\u003CHandOffButton action=\"…\" prompt={ask(handOff)} \u002F>` and\n  `\u003CHostModelContext content={context(view)}>`. A hand-off is a typed spec: an `intent`\n  key from `messages\u002F{en,de}.ask-ai.ts` (static, no tool names) or a static view\n  `summary`, plus `ids`, `facts`, `untrusted` (engine text, fenced); no `tools`: a hand-off\n  names no tool, the module instructions and the tool descriptions steer the model.\n  Reference:\n  `src\u002Fwidgets\u002Fremediation.ts`; the rules are CLAUDE.md invariant 6, enforced by\n  `scripts\u002Fhand-off-refs.test.mjs`.\n\n## Step 5: verify\n\n```bash\n# while you iterate: the test files you touch (coverage off, the packages under them built first)\npnpm test:one packages\u002Fconnectors\u002Fengine\u002Fengine-connector\u002Fsrc\u002Ftools\u002F\u003Cdomain>.wire.test.ts\n# then the package and every package that depends on it (the server's goldens included)\npnpm exec turbo run typecheck test lint --filter=...@miragon\u002Fpulse-engine-connector\n# the bar for every change: build, typecheck, test, lint, format\npnpm check\n# for widget\u002Fshell changes additionally:\npnpm --filter @miragon\u002Fpulse-mcp-server test:host\n```\n\nA file run alone fails the package's coverage floors, since it loads only part of the code, so\nuse `pnpm test:one` for it; `pnpm test` keeps enforcing the floors.\n\nA new or changed tool changes the wire surface: add its name to\n`apps\u002Fmcp-server\u002Ftest\u002Fexpected-tools.ts`, then refresh the per-toolset goldens with\n`pnpm --fail-if-no-match --filter @miragon\u002Fpulse-mcp-server golden:update` (it builds the\nconnectors first) and commit the\n`test\u002F__golden__\u002F` diff (descriptions and schemas are what the model reads; review them).\nThe budget counts the model-visible tools\u002Flist text plus the server instructions of each\ntoolset. If `char-budgets.json` grew, `pnpm lint` fails until the commit carries a\n`Ratchet-Exception: \u003Cwhy the model must read more>` trailer. Trim wording first.\n\n`pnpm typecheck` is the **only** automated check that type-checks widget `.tsx` code\n(`tsc -p tsconfig.widgets.json`); never skip it. For widgets also do a manual render\ncheck: `pnpm play` (playground, build and server in one command), then call the\ntool in the MCP Inspector `pnpm play` starts next to the server (it prints the URL). The default boot\nis read-only (the boot log names the toolsets). To exercise a write tool or button, run\n`pnpm play --admin` (`--deploy` for deployments). Run `pnpm format:check` (or\n`pnpm format`) before committing.\n","# add-bpm-feature: new tool\u002Fwidget in the engine module\n\nThe engine module never registers operations tools with a raw `server.registerTool()`. Tools go\nthrough the registrar (`createToolRegistrar` from `@miragon\u002Fpulse-widget-shell\u002Fserver`);\nengine access goes through `withEngine`\u002F`resolveEngine` (per-call `engine` override >\n`ENGINE_DEFAULT` > the profile file's `defaultProfile` > the only profile, refused ones counted;\na refused target fails with `ENGINE_PROFILE_REFUSED`, CLAUDE.md invariant 2). A view is\nregistered in three places (CLAUDE.md invariant 3). Follow the steps for the path you need.\n\n## Step 0: pick the render path\n\n| You need…                                                        | Path                                                                                  |\n| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------- |\n| JSON data for the model                                          | Registrar tool in `src\u002Ftools\u002F\u003Cdomain>.ts` (Steps 1–3)                                 |\n| A view for the user                                              | a cockpit route plus its builder and `*_data` feed (Step 4); never a second view tool |\n| App-only JSON for in-widget refresh\u002Fnav                          | `*_data` feed in `DATA_FEEDS` of `src\u002Fdata\u002Ffeeds.ts` (Step 4)                         |\n| An answer the CLI's workflow commands give too (inspect, status) | a composite on `@miragon\u002Fpulse-c7-core` over `src\u002Flib\u002Fcore-adapter\u002F` (Step 2b)        |\n\n## Step 1: input schema next to the tools\n\nAdd the Zod input schema to `packages\u002Fconnectors\u002Fengine\u002Fengine-connector\u002Fsrc\u002Fschemas\u002F\u003Cdomain>.ts` and\nexport it from `src\u002Fschemas\u002Findex.ts`. Existing style (from `schemas\u002Fprocess-instances.ts`):\n\n```ts\nexport const listProcessInstancesInput = z.object({\n  processDefinitionKey: z.string().optional(),\n  businessKeyLike: likeParam(),\n  active: flagParam(),\n  withIncidents: flagParam(\"true = only instances with an open incident\"),\n  firstResult: firstResultParam,\n  maxResults: maxResultsParam(), \u002F\u002F capped at MAX_PAGE_SIZE (100); default page 20\n  sortBy: z.enum([\"instanceId\", \"definitionKey\"]).optional(),\n  sortOrder: sortOrderParam,\n})\n```\n\n**Descriptions.** Describe a field only where the tool gives it its own meaning: what `false`\nor an omitted value selects when no shared rule says it (`withIncidents`: open only), a unit or\nformat the name does not say (`retryTimeout`: in milliseconds), an id that names another object\nthan its name suggests, or which views take it (`engine_open_cockpit`). A description that\nrestates the name or the tool description goes. What a parameter means on every tool\n(`engine`, paging and sorting, `processDefinitionKey`\u002F`processDefinitionId`, `activityId`,\n`incidentType`, `…Like`, the boolean filters, the date bounds, the variable shape) is said once\nin `engineInstructions` (`packages\u002Fconnectors\u002Fengine\u002Fengine-connector\u002Fsrc\u002Finstructions.ts`),\nand the paging, sorting, `…Like`, flag, date and variable-map helpers in `schemas\u002Fshared.ts`\ncarry no description of their own, so a field built from them needs none (`flagParam` and\n`engineDateParam` take one where the field means more; `wholeVariableParam` keeps its own). A\nnew shared meaning goes into the instructions, briefly, not onto each tool. A safety rule stays\nat the write it guards (the cut-value rule on the variable writes), where a host that drops the\nserver instructions still shows it. `apps\u002Fmcp-server\u002Ftest\u002Ftools-list.golden.test.ts`\nfails when one description repeats on more than two model-visible tools.\n\nA list\u002Fquery tool pages with the shared params from\n`schemas\u002Fshared.ts` (`firstResultParam` + `maxResultsParam(defaultPageSize)`), never a\nhand-rolled `maxResults` (a `*_data` feed spreads `pagingShape` from the same file, the one paging\ndefinition in its described, default-free form): `src\u002Ftools\u002Flist-tools.test.ts` treats every registrar tool that\ntakes `maxResults` as a list and fails it without the cap or the envelope (Step 2). The\nREST calls themselves use the generated SDK (imported from\n`@miragon\u002Fpulse-c7-client\u002Fsdk`). If the endpoint is missing there, the OpenAPI spec\nchanged and you need `pnpm generate`, not a hand-written fetch. The engine's REST rules the\nhandler applies (`toEngineDate`, `engineLike`, `complementaryFlags`, `engineSorting`,\n`toEngineVariables`, `incidentRecovery`, `RAW_VARIABLES`, `isMissing`) come from\n`@miragon\u002Fpulse-c7-core`, their one home, which the CLI uses too; the reads bound to the SDK\n(`fetchJobStacktrace`, `readProcessInstanceVariables`, `readBatch`) from\n`@miragon\u002Fpulse-c7-client`. Those reads throw every 404: a handler that reads a missing\nresource as \"none\" wraps the read in `noneIfMissing` (`src\u002Flib\u002Fcore-adapter\u002Fengine-port.ts`,\nc7-core's `isMissing`), so a wrong REST root still fails loud.\n\n### Parameter naming guide\n\nInputs are STRICT (the registrar is always strict; raw `server.registerTool()` registrations use\n`strictToolInput(...)` from `@miragon\u002Fpulse-widget-shell\u002Fserver`, and the boot refuses any other\ninput schema that lets an unknown key through): an unknown key is a tool error listing the valid keys. So one name per concept across every tool: a model that learned\n`assignee` on one tool must not be refused it on the next. Renames ship without aliases.\n\n| Concept                  | Name                                                                                                                                                                                                                                                   |\n| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| Process definition       | `processDefinitionKey` (all versions), `processDefinitionId` (one version); never bare `key`                                                                                                                                                           |\n| Entity ids               | `\u003Centity>Id`: `processInstanceId`, `deploymentId`, `jobId`, `activityId`; never bare `id`, never `elementId`                                                                                                                                           |\n| Several values           | `\u003Cname>In`, an array (`processDefinitionKeyIn`); merged with the single form via `engineKeyList`                                                                                                                                                       |\n| User filter              | `assignee` (the user a task is assigned to); a write that sets a user takes `userId`                                                                                                                                                                   |\n| Business key \u002F substring | `businessKey` exact, `businessKeyLike`\u002F`nameLike` substring via `likeParam()`: `engineLike` wraps a `%`-less value                                                                                                                                     |\n| Time bounds              | `\u003Cevent>After`\u002F`\u003Cevent>Before` (`startedAfter`, `finishedBefore`, `incidentTimestampAfter`) via `engineDateParam()`: ISO 8601 in, `toEngineDate` out                                                                                                   |\n| Boolean filters          | `flagParam()` + `complementaryFlags` (active\u002Fsuspended, …) or `trueOnly`: the engine IGNORES `false` (never sent); a both\u002Fneither pair is refused; a new `complementaryFlags` pair is named in `engineInstructions` (`instructions.test.ts` checks it) |\n| Paging \u002F result size     | `firstResult` + `maxResults` from `firstResultParam` + `maxResultsParam` (above; also for a top-N cap: never `limit`)                                                                                                                                  |\n| Sorting                  | `sortBy` + `sortOrder`, sent as a pair by `engineSorting`                                                                                                                                                                                              |\n\nThe engine's own query names may differ (`taskAssignee`, `processInstanceBusinessKey`,\n`withIncident`): the handler maps them, the schema keeps the guide's name. Every\nfilter a tool advertises is pinned on the wire in `src\u002Ftools\u002Ffilters.wire.test.ts`\n(page query AND `\u002Fcount`); a retired spelling (`key`, `id`, `limit`, `elementId`, …) on\nany module tool fails `apps\u002Fmcp-server\u002Ftest\u002Fstrict-input.e2e.test.ts`, so a parameter that\ninstructions or a description quote but the tool does not take turns into a refused call.\nThe one quoted filter set in the instructions (the retry playbook's `engine_list_jobs`\nfilters) is held to `listJobsInput` by `src\u002Finstructions.test.ts`; a newly quoted parameter\ngets its own pin there. Widget Ask-AI hand-offs and model contexts never quote parameters:\nthey pass typed `ids` (see Step 4).\n\n## Step 2: register the tool\n\nAdd the tool in `packages\u002Fconnectors\u002Fengine\u002Fengine-connector\u002Fsrc\u002Ftools\u002F\u003Cdomain>.ts`. The reference template is\n`engine_list_process_instances` from `src\u002Ftools\u002Fprocess-instances.ts`:\n\n```ts\nimport { listProcessInstancesInput } from \"..\u002Fschemas\u002Findex.js\"\nimport type { ToolRegistrar } from \"@miragon\u002Fpulse-widget-shell\u002Fserver\"\nimport { complementaryFlags, engineLike, engineSorting } from \"@miragon\u002Fpulse-c7-core\"\nimport { getProcessInstances, getProcessInstancesCount } from \"@miragon\u002Fpulse-c7-client\u002Fsdk\"\nimport { paginatedListOutput, toPaginatedList } from \"..\u002Flib\u002Fmcp\u002Fpagination.js\"\nimport type { EngineRegistry } from \"..\u002Flib\u002Fengine\u002Fresolve-engine.js\"\nimport { engineParamShape, withEngine } from \"..\u002Flib\u002Fengine\u002Fwith-engine.js\"\n\nexport function registerProcessInstanceTools(register: ToolRegistrar\u003CEngineRegistry>) {\n  register({\n    name: \"engine_list_process_instances\",\n    category: \"process-instances\",\n    description: \"List running process instances.\",\n    annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },\n    inputSchema: { ...listProcessInstancesInput.shape, ...engineParamShape },\n    outputSchema: paginatedListOutput,\n    handler: withEngine(async (client, args) => {\n      \u002F\u002F The filters in the engine's format (naming guide), shared by the page\n      \u002F\u002F and its \u002Fcount twin (fetched in parallel), so the envelope's total is honest.\n      const filters = {\n        processDefinitionKey: args.processDefinitionKey,\n        businessKeyLike: engineLike(args.businessKeyLike),\n        ...complementaryFlags(args, \"active\", \"suspended\"),\n      }\n      const [items, count] = await Promise.all([\n        getProcessInstances({\n          client,\n          query: {\n            ...filters,\n            firstResult: args.firstResult,\n            maxResults: args.maxResults,\n            ...engineSorting(args),\n          },\n        }),\n        getProcessInstancesCount({ client, query: filters }),\n      ])\n      return toPaginatedList(items, count, args.firstResult)\n    }),\n  })\n}\n```\n\nA list tool's description says what it lists, never the paging contract\n(`src\u002Ftools\u002Flist-tools.test.ts`).\n\nNon-negotiables:\n\n- Every tool carries a `category` matching its domain file name (e.g.\n  `\"process-instances\"`, `\"tasks\"`; `task-form.ts` uses `\"tasks\"`,\n  `incident-issue.ts` uses `\"incidents\"`).\n- `inputSchema` spreads `...engineParamShape` so every tool accepts the per-call\n  `engine` override.\n- `handler` is wrapped in `withEngine(...)`. Never construct, cache, or import a client\n  directly; `withEngine` resolves the engine from the registry per call.\n- Tool names are `engine_\u003Cverb>_\u003Cnoun>` in snake_case.\n- Write tools that only flip state return a small `{ success: true, … }` object instead\n  of the raw (often empty) REST response.\n- Destructive\u002Fadmin-grade tools (delete, modify, suspension, deployments, migrations,\n  batches, engine-wide broadcasts like signals, the external-task worker protocol) and\n  reads that only feed an admin write (`engine_get_batch`,\n  `engine_get_activity_instance_tree`) must\n  be added to `ADMIN_ONLY_TOOLS` in `src\u002Flib\u002Fmcp\u002Ftoolsets.ts` so the\n  `engine:read-only|operations|admin` toolset filtering stays correct. `read-only`\n  membership is derived from `readOnlyHint: true`. `src\u002Flib\u002Fmcp\u002Ftoolsets.test.ts` enforces\n  the rule structurally over every registrar tool (`destructiveHint` ⇒ admin-only,\n  read-only ⇒ `readOnlyHint`, every write states `destructiveHint` explicitly), and\n  `src\u002Fplugin.test.ts` over every tool the module registers, feeds included (the\n  read-only floor holds only `readOnlyHint` tools); get the annotations right, never\n  edit the test to pass.\n- Toolsets fail CLOSED: with no suffix a deployment runs `read-only` (no OAuth) or\n  `operations` (OAuth); `admin` is only ever reached by naming it. So an `operations`\n  tool is on for every authenticated default deployment and an `admin` tool for none:\n  place a new write deliberately. A tool that runs code inside the engine\n  (`engine_create_deployment`) additionally sits behind its own strict env opt-in\n  (`ENGINE_ALLOW_DEPLOYMENTS=true`) and is not registered without it; follow that\n  pattern for anything comparable instead of trusting the toolset alone.\n\n### Annotation conventions\n\n| Operation                                     | Annotations                                                             |\n| --------------------------------------------- | ----------------------------------------------------------------------- |\n| Read \u002F list \u002F get                             | `{ readOnlyHint: true, idempotentHint: true, openWorldHint: true }`     |\n| Set-style write (variable, retries, assignee) | `{ destructiveHint: false, idempotentHint: true, openWorldHint: true }` |\n| Other write (start, complete, correlate, …)   | `{ destructiveHint: false, openWorldHint: true }`                       |\n| Delete \u002F irreversible (delete, modify)        | `{ destructiveHint: true, openWorldHint: true }` (admin-only)           |\n| Engine-wide \u002F worker \u002F deploy                 | `{ destructiveHint: true, openWorldHint: true }` (admin-only)           |\n\nMCP reads an ABSENT `destructiveHint` as `true`, so every write states it; `false` is a\ndeliberate claim, not a default. `idempotentHint: true` marks writes that repeat to the\nsame state. Every engine tool carries `openWorldHint: true` (it talks to an external\nengine); a tool that only reads the server's own config (`engine_list_engines`) says\n`openWorldHint: false`.\n\n## Step 2b: a read composite on c7-core\n\nA question the CLI answers too (where an instance waits, how an engine is doing) runs on\n`@miragon\u002Fpulse-c7-core` (`packages\u002Fconnectors\u002Fc7\u002Fc7-core`, the core of the Camunda 7 REST\ndialect family c7, which the engine module spans), the workflow domain both share, never on a\nsecond copy here. A rule both need that it\nlacks goes into that package (a leaf: no dependency, data rather than wording).\nThe pattern is `engine_inspect_process_instance` and `engine_status`\n(`src\u002Flib\u002Fcore-adapter\u002Fcomposites.ts`, registered like any registrar tool):\n\n- Map nothing by hand: the port (`src\u002Flib\u002Fcore-adapter\u002Fengine-port.ts`) sends every operation\n  the composite asks for as its route of c7-core's `CORE_ROUTES` (`routedTransport`) through the\n  engine's client, so a composite that needs an operation c7-core does not send yet adds it to\n  c7-core's `CORE_OPERATIONS`, and `src\u002Flib\u002Fcore-adapter\u002Fengine-port.test.ts` holds every\n  operation of c7-core's lists to a reading route.\n- Word c7-core's error reasons in `src\u002Flib\u002Fcore-adapter\u002Fworkflow-errors.ts` (`[CODE] message` in the\n  tool's parameter names, no tool names).\n- Return data for the model: drop `next` (it would name tools a toolset may lack), cut\n  variables with `truncatePlainVariables`, and cap every list c7-core leaves uncapped for\n  the CLI (today wait states, incidents, timeline, variables, definitions, topics), naming each\n  cut in `truncated`. The cap bounds the result, not the reads. Pin the wire and each cap in\n  `src\u002Flib\u002Fcore-adapter\u002Fcomposites.wire.test.ts` (inspect) and\n  `src\u002Flib\u002Fcore-adapter\u002Fcomposites-status.wire.test.ts` (status), over the helpers of\n  `src\u002Flib\u002Fcore-adapter\u002Fcomposites.test-support.ts`.\n- How an engine is doing has one answer: mask incidents with c7-core's `messageSignature`,\n  count one cause with its `causeKeyOf`\u002F`largestCause` and judge with its `engineVerdict`\n  (`ok | warning | critical`) by the `ENGINE_HEALTH_*` thresholds (`pulse status`: the\n  defaults), as `engine_status` and the cockpit's health route (`src\u002Fdata\u002Fhealth-data.ts`)\n  do. Neither text names the other's verdict.\n\n`src\u002Flib\u002F` is split by job, and each helper goes into the folder of its job: `engine\u002F`\n(routing: `resolveEngine`, `withEngine`, the cockpit links, passthrough auth), `core-adapter\u002F`\n(the c7-core port, the composites, their error wording), `bpmn\u002F` (the definition XML,\nBPMN and task-form reading, completing a user task by its form), `mcp\u002F` (the tool surface:\ntoolsets, the paging envelope, the bounds on variable values and failure text) and `shared\u002F`\n(pure code the widgets run too). `src\u002Fdata\u002F` and `src\u002Flib\u002F` never import `src\u002Ftools\u002F`,\n`src\u002Fwidget-tools\u002F`, `src\u002Fwidgets\u002F` or the connector's root (`index`, `plugin`, `module`)\n(`connector-data-and-lib-point-down`): a helper a builder needs from a tool moves down into the\nlib folder of its job (the task form's `buildTaskFormSchema` into `bpmn\u002F`).\n\n## Step 3: wire a new domain file\n\nOnly when you created a new `src\u002Ftools\u002F\u003Cdomain>.ts`: add\n`registerYourDomainTools(register)` to `registerTools` in\n`packages\u002Fconnectors\u002Fengine\u002Fengine-connector\u002Fsrc\u002Ftools\u002Findex.ts`, mirroring the existing calls.\n\n## Step 4: widget path (only for UI features)\n\nWidget components live in `packages\u002Fconnectors\u002Fengine\u002Fengine-connector\u002Fsrc\u002Fwidgets\u002F` and receive their data as\na `data` prop. Compose them from the design kit `@miragon\u002Fpulse-ui` and the view runtime\n`@miragon\u002Fpulse-widget-shell\u002Fwidgets` (pulse-ui holds what touches no host: primitives,\ncomponents, tone maps, `cn`, the formatters, `Icon`, the table cells; widget-shell holds what\nreads the host or calls a tool, and neither re-exports the other's names); never re-inline\nthese primitives. Widgets are browser code: at runtime they import only their own widgets,\n`src\u002Fmessages`, `src\u002Ftool-names.ts`, the view models, `@miragon\u002Fpulse-widget-shell\u002Fwidgets` and the\nview stack, plus `src\u002Flib\u002Fshared` (pure code the server runs too, held to the same allowlist);\n`src\u002Ftools`, `src\u002Fdata`, the feed contracts, the plugin, the module,\n`@miragon\u002Fpulse-c7-client` and anything else only as types (`import type`), and the other\n`src\u002Flib` folders not even as types: a type both sides need is declared once in\n`src\u002Flib\u002Fshared` (`TaskCompletion`). Data arrives through the view tool and the `*_data` feeds\n(`connector-widgets-are-browser-code` and `connector-widgets-only-shared-lib` in\n`.dependency-cruiser.cjs`):\n\n- `ViewDataState` is the loading\u002Ferror\u002Fno-data guard (no inline Alert\u002Floading ternary):\n  `onRetry` is required (`paged.retry`, a query's `refetch`), though a refusal no retry\n  changes (`isPermanentError`: a refused profile, a read-only engine) shows no Try again, and\n  the view's query client never retries one; loading is a `skeleton`\n  slot under a polite sr-only status (\"Ansicht wird geladen…\" unless `loadingText`),\n  no data pulse-ui's `EmptyState`\n- Self-fetching widgets: skeleton + error via `QueryFallback` (+ `TableSkeleton`). A\n  missing `isError` branch means an eternal skeleton; `onRetry` is required\n- Errors speak through widget-shell's `describeError(error, locale)`: an engine or transport\n  failure (503\u002Funreachable, timeout, a tool this server lacks, 401\u002F403, 404, an engine\n  exception) becomes what happened (\"Die Engine prod ist nicht erreichbar (HTTP 503)\")\n  and what you can do, the raw \"[503 ProcessEngineException] …\" collapsed under the\n  \"Details\" of pulse-ui's `ErrorState`: never raw engine text as a headline or in a toast.\n  `ViewDataState`, `QueryFallback`, `useDetailView` (its stale notice: an inline warning\n  `ErrorState`), `ConfirmDialog`, `useActionFeedback` and the view's own failed call\n  (`ViewFrame`) all use it; a widget's own error line does too\n  (`ErrorState` with `describeError(...)`), and `errorTitle` heads only a failure\n  `describeError` cannot classify\n- A view that reads its feed directly calls widget-shell's `useViewData` (from\n  `@miragon\u002Fpulse-widget-shell\u002Fwidgets`): the handed-in tool result always seeds a live\n  query, so Refresh and a write's invalidation re-read it (#341). Widgets import every\n  query hook (`useToolQuery`, `useToolQueries`, `useToolMutation`, `useViewData`,\n  `useQueryClient`) and the host seam (`useViewHost`, `HostModelContext`) from the view\n  runtime, never from `@modelcontextprotocol\u002Fext-apps` (ESLint); tests render them under\n  `TestViewHost`, whose `locale` (else an outer `LocaleProvider`'s, else English) and\n  `host.timeZone` the formatters render in, as `LocaleGate` publishes the host's: a widget test\n  sets no `setFormatLocale` of its own and reads the same on every machine\n- Across engines (the cross-engine overview) a widget reads each engine's feed under the key\n  its other readers use (`useToolQueries`, one read per engine), and a figure over engines is\n  a sum of counts (`foldFleetLandscape` in `src\u002Flib\u002Fshared\u002Ffleet-landscape.ts`), never a\n  rate, a duration or a ranking: engines run different processes\n- `formatTimestamp`\u002F`formatDate`\u002F`formatTime`\u002F`formatClock`\u002F`formatInstant`\u002F\n  `formatRelativeTime` (dates) and `formatNumber`\u002F`formatCount`\u002F`formatCompactCount`\u002F\n  `formatPercent`\u002F`formatPercentPoints`\u002F`formatPeriod`\u002F`formatDuration`\u002F`shortId`\u002F`truncate`,\n  all from `@miragon\u002Fpulse-ui` (the same set the VS Code extension reads from\n  `@miragon\u002Fpulse-ui\u002Fformat`): no local format\n  helpers, no `toFixed`, no `toLocaleString()` without a locale, no hand-built\n  `Intl.NumberFormat` (canonical duration style\n  \"3m 7s\", \"2d 3h\" \u002F German \"3 Min. 7 s\"; a lower bound \"≥ 2,000\", compact \"10k+\";\n  \"3,7 %\", \"+0,2 Pp.\", \"7 Tage\")\n- Tables: widget-shell's `ListTable` is the frame: a named, keyboard-reachable scroll region\n  (`role=\"region\"`, `tabIndex=0`; `ariaLabel` is required), so a wide table never widens\n  the document at 760 px (the host simulation pins it). A column a narrow view can do\n  without takes `hideBelow: \"sm\" | \"md\" | \"lg\"` (the REGION's width: 640\u002F768\u002F1024 px;\n  `md` hides inline, shows in fullscreen), the row actions are the LAST column with\n  `sticky: true` (pinned at the region's right edge, Retry never scrolled away). Rows\n  stay hand-composed `\u003Ctr>` + `Td`, one cell per column (the rules address cell\n  positions). An id column is `IdCell` (`IdText` inline): Geist Mono, shortened\n  (\"5f0ce78e…\", whole for screen readers and in `title`), a copy button with a toast.\n  Nothing to show: pulse-ui's `TableEmptyState`, `variant=\"done\"` (`CircleCheck`, success) for\n  \"nothing left to do\" (\"Keine offenen Incidents\"), the default `empty` (quiet `Inbox`)\n  for no data; `description`, `action`. The Komet (`KometMark`, `AppEmptyState`) only on\n  APP-level surfaces (landing, first start, \"no engines\"), never in a widget\n- Type roles (pulse-ui's `TYPE_SCALE`): a widget's page title is the `WidgetHeader`\n  (28\u002F700, an h2 below the view, `as`), a section `text-section` (21\u002F600), running text\n  `text-body` (16), dense tables and controls `text-dense` (14), captions and meta\n  `text-small` (13, the floor); no `text-xs`\u002F`text-[11px]`, no Tailwind sizes in kit\n  code (`scanTypeScale` from `@miragon\u002Fpulse-ui\u002Ftesting`; connectors adopt it with `rules:\n[\"below-floor\"]` first)\n- From pulse-ui: `Section` (collapsibles); `Th`\u002F`Td` (cells);\n  `WidgetHeader` (`badge`\u002F`titleSuffix`\u002F`size=\"detail\"`\u002F`as`, no icon tile) + `VersionChip`\n  (hero\u002Fdetail headers); `KpiGrid` (KPI strips, incl. `variant=\"soft\"`; a\n  clickable cell says what the click does in `actionHint`, never `ariaLabel`). From the view\n  runtime: `WidgetShell` (page container, split into View + Shell only when the body is\n  embedded elsewhere)\n- Where and what (#322 U4, U13): the cockpit's head names the engine and its environment (a\n  text chip, \"PROD\"), so a route view's `WidgetHeader` `sub` carries only the time it read\n  (widget-shell's `ViewMeta` with `asOf`) and its own facts; a route widget takes `engine` and\n  its ids as required props and builds its feed args from them alone. A write confirmation\n  adds `engineTargetLines(t, engineId, useEngineEnvironment(engineId))`\n  (`widgets\u002Flib\u002Fengine-context.tsx`). Names before ids: the\n  design kit's `NamedId` (`@miragon\u002Fpulse-ui`: an activity's or process's name, the\n  id a quiet mono line; the same look in every module), `InstanceLabel`\n  (`widgets\u002Flib\u002Fidentity.tsx`: the business key, the UUID a copyable `IdText` chip; the\n  words of `instanceName`); a drill passes the target's `label` so its breadcrumb reads\n  the name. The builder reads names as enrichment (`fetchNamesByDefinition` with\n  `processNameIn`\u002F`elementNameIn`, `fetchDefinitionsById`, `fetchBusinessKeys`), every\n  diagram name through widget-shell's `readBpmnNames`\u002F`parseBpmnNames` (never a regex of your\n  own: every view reads the same diagrams), and a view that shows the engine's definition\n  name passes its rows through `nameUnnamedDefinitions` (a Modeler pool around an unnamed\n  process names it, as in every view). A row keeps ONE deterministic action;\n  its vendor link and chat hand-offs sit behind `MoreActions`, as `variant=\"menuitem\"`\n  entries (`HandOffButton`, `OpenInCockpitLink`); a confirmation names an instance with\n  `instanceTargetLines` (business key, then short id)\n- Status: a `ToneVariant` (`danger`\u002F`warning`\u002F`success`\u002F`info`\u002F`neutral`; open incident or\n  failed job without retries = `danger` (`severityTone`\u002F`countTone` from `@miragon\u002Fpulse-ui` is\n  the one ladder for incident and job counts in every module, a count without a state stays\n  neutral; an instance's or a step's state takes its tone from `SITUATION_TONES`, the table the\n  VS Code webview reads too: running `info`, suspended and ended `neutral`), retries left or\n  degraded = `warning`; an\n  engine's own state is its verdict's tone, `VERDICT_TONE`, never a ladder over its counts)\n  shown via pulse-ui's `StatusBadge` (tone and words required)\u002F`CountPill`\u002F`KpiCell.tone` or its\n  `TONE_DOT`\u002F`TONE_BORDER`\u002F`TONE_ICON`\u002F`TONE_TINT`\u002F`TONE_SOFT`\u002F`TONE_INK` maps (a tone\n  from the wire through `resolveTone`), next to readable words; never a coloured\n  number, never a palette class or raw colour; text on a tint is black (`TONE_SOFT`), the\n  `-ink` role is only for text in a tone directly on the card or page (an inline error)\n- Icons: Lucide via `Icon` (`import { FileSearch } from \"lucide-react\"`; the package pins\n  devDependency `lucide-react` `1.70.0`, see CLAUDE.md invariant 6), never a Unicode glyph\n  or emoji, at 16 px (`dense` in rows; `scanIconSizes` allows 16 and 24 only); a chat\n  hand-off is widget-shell's `\u003CHandOffButton action=\"explainError\" prompt={ask(…)} \u002F>`\n  (`@miragon\u002Fpulse-widget-shell\u002Fwidgets`: the product's one table of functions, each with its\n  Lucide icon and a label that names the chat, \"Fehler im Chat erklären\" \u002F \"Explain error in\n  chat\", the same in every module; `AskAiButton` is banned in connector widgets, so a new\n  function gets its entry and both labels in widget-shell), placed after the deterministic action; `OpenInCockpitLink` gets `vendor={data.engineVendor}` (the\n  builder sets it from `provider.branding.displayName`)\n- Copy: en + de catalogs in the brand voice (du, AI, \"CIB seven\", no dash connector,\n  errors say what happened and what you can do, `Ja`\u002F`Nein` for Booleans) and the terms of\n  `src\u002Fmessages\u002FGLOSSARY.md` (a count the VS Code webview shows too takes its word from\n  `COUNT_TERMS`); counts go in formatted (`formatNumber`) and the entry picks\n  the noun's number (`countOf` from `messages\u002Fplural.ts`). The gates: `catalogTextFindings`\n  over every catalog and en\u002Fde key parity of the widget catalogs (`messages\u002Fcatalog-text.test.ts`),\n  the glossary\n  (`messages\u002Fglossary.test.ts`), `scanGlyphs` over all of `src` and `scanColors` over\n  `src\u002Fwidgets` (`src\u002Fbrand-gates.test.ts`; the gates, the glossary and the count terms come\n  from `@miragon\u002Fpulse-ui\u002Ftesting`)\n- A pair: the overview, a process, its instances, an instance, an incident and the history\n  have a counterpart in the VS Code webview, which shares only pulse-ui's foundation with the\n  widgets. A field, a count or an action you add to one says in its pull request whether the\n  webview gets it and updates `docs\u002Fvscode\u002Fparity.md`; its content takes props and callbacks\n  only, no host context (as `process-definitions-table-view.tsx` with `renderActions`)\n- BPMN: viewer lifecycle and the fixed light canvas via `useBpmnViewer` (the container\n  is a forced-light island, `data-theme-mode=\"light\"`, on the paper token) +\n  `BpmnZoomControls` (light in every theme, Lucide). The frame height comes from the\n  diagram's size (`height` from the hook: its height at the scale bpmn-js draws it, never\n  past 1:1, 240–560 px, read from the XML's DI before the import, so the frame does not\n  jump): pass no fixed `height` to `BpmnDiagram` unless a layout needs one. The process flow\n  draws only engine reads: the open incidents, or as \"Running\" the `instance-count`,\n  `incident` and `failed-jobs` badges of `engine_activity_statistics_data` (every version\n  summed onto the latest diagram, mounted only once picked), or as \"Throughput\" its history\n  source as heat (`BpmnHeatmap`, the ramp `BPMN_TOKENS.heat` read from the theme's variables;\n  a read that loads the engine's database: see CLAUDE.md invariant 7).\n  Overlay and legend colours are widget-shell's ONE token module (`BPMN_TOKENS`,\n  `widget-shell\u002Fsrc\u002Fui\u002Fbpmn-tokens.ts`: incident = danger, running\u002Fopen task = blue on the\n  info tint, counts white on blue or danger; strokes ≥ 3:1, badge text ≥ 4.5:1, drift- and contrast-tested) through `HIGHLIGHT_COLORS`\n  (`src\u002Fwidgets\u002Fbpmn-highlights.ts`); the CSS is\n  `injectBpmnOverlayCss()`. No pulse: an open user task is a heavier dashed stroke\n\n**Paged list recipe** (every list that can exceed one page follows it; reference:\n`src\u002Fwidgets\u002Fprocess-instances\u002Flist.tsx`):\n\n1. Feed: accept `firstResult`\u002F`maxResults` (+ server-side filter params like\n   `businessKeyLike`\u002F`nameLike`) and return the full filtered total (via the matching\n   `\u002Fcount` endpoint) so the footer is honest.\n2. Widget: `usePagedListView` (owns search state, 300 ms debounce, the server-side\n   `searchArg`, and the drop-initialData-on-interaction rule; chips go into\n   `filtersActive` + the `args`) → `FilterBar` (with `searchAriaLabel`, the placeholder's\n   words without the ellipsis, and `chipsAriaLabel` for a chip row: pulse-ui's default\n   name is a generic \"Filter\"; `src\u002Fwidgets\u002Fsearch-labels.test.tsx` pins every search box\n   by its name) → `PagedRows` (marks the rows while they\n   are the previous result) around `ListTable` (frame + `Th` header row;\n   rows stay hand-composed `\u003Ctr>` + `Td`) or a `RowCard` stack → `TableEmptyState`\n   (distinguish \"no match\" when `interacted`) → pulse-ui's `PagedListFooter`\n   (`paged` as is, `noun` from the catalog; it renders the stale page-0 error and the load-more\n   error, each with its own retry and the raw reason under Details, + \"Showing X of Y\" in\n   the list's one live region + Load more; deliberately not infinite scroll; rendered\n   under an empty result too). Guard the whole view on\n   `!paged.firstPage` only: a search keeps the previous rows (and the search box)\n   mounted while its page 0 is in flight. While it does, `paged.stale` is true and\n   `firstPage`\u002F`total` are the PREVIOUS result's: a header count, the filters it\n   covers and the model context read the payload's echo (`data.filters`), never the\n   request's args (reference: the instance list's `listFilters`).\n\nThe engine module has ONE view, `engine_open_cockpit` → `CockpitApp` (CLAUDE.md invariant 3). New\nUI is a **cockpit route**, never a second view tool. On the server a route is five entries;\n`COCKPIT_ROUTES` and the widgets' route switch fail to compile without it, and\n`src\u002Fdata\u002Fcockpit-start.test.ts` fails on a route whose feed is missing from `DATA_FEEDS`:\n\n1. `src\u002Fview-models.ts`: a section in `CockpitView` with the ids the route reads, and its\n   data type in `CockpitStartData`.\n2. `src\u002Fdata\u002F`: ONE builder for the route's data (`\u003Croute>-data.ts`).\n3. `src\u002Fdata\u002Ffeeds.ts`: the route's `*_data` feed,\n   `dataFeed({ name, title, description, shape, read })`, in `DATA_FEEDS`; `read` calls the\n   builder. `src\u002Fwidget-tools\u002Fdata-feeds.ts` registers every feed of `DATA_FEEDS`.\n4. `src\u002Ftool-names.ts`: the feed's name as an `ENGINE_*_DATA` constant.\n5. `src\u002Fdata\u002Froutes.ts`: the route's entry in `COCKPIT_ROUTES`: the ids it `needs` and also\n   `takes` (fields of its own `CockpitView` section, checked at compile time), its `start` (its\n   feed's read with the first page's args) and its `facts`. `src\u002Fwidget-tools\u002Fcockpit.ts`\n   builds the `view` enum and checks the ids from it, and `src\u002Fdata\u002Fcockpit-start.ts`\n   dispatches the start view and its facts. A start param new to the cockpit also gets its\n   described field in `startViewShape` (`src\u002Fwidget-tools\u002Fcockpit.ts`; `StartParam` is read\n   off `CockpitView`, so a missing field fails to compile), and a route that takes an existing\n   param adds its name to that param's description (the tools\u002Flist golden shows the change).\n\nIn the widgets a route is its widget, a case in `CockpitRoute`\n(`src\u002Fwidgets\u002Fcockpit-app\u002Froutes.tsx`) that renders it with `seedOf(seed, section)` as its\n`data` and the route's `engine` and ids as required props, a `NavIntent` and an emitter (below)\nand its en\u002Fde messages. A composed view (a dashboard, the process detail) is ONE component that\ncomposes its widgets in a `ViewStack`.\n\nThe start data is exactly the feed's page-0 payload: build it with the size the widget\npages by (`LIST_PAGE_SIZE`, `HISTORY_PAGE_SIZE` in `src\u002Fview-models.ts`), so the route's\nwidget seeds its feed with it and refreshes after a write without a fetch on mount\n(`src\u002Fdata\u002Fcockpit-start.test.ts` pins the seed against the feed). The facts are the\nmodel-facing result: compact JSON, English keys, ids and counts (a `null` count is\nunknown, never 0), engine text clipped, never XML. A route needs an emitter in the cockpit\ntoo: a `DrillButton`, a KPI cell's `onClick`, or a head section in\n`src\u002Fwidgets\u002Fcockpit-app\u002Fapp.tsx`, each through `useNav()` with a `NavIntent`\n(`src\u002Fwidgets\u002Fnavigation.ts`) that `intentToView` (`src\u002Fwidgets\u002Fnav-core.ts`) maps onto the\nroute. A route that nothing emits and `engine_open_cockpit` cannot open is dead.\n\n`apps\u002Fmcp-server\u002Ftest\u002Fview-registry.test.ts` holds the view registry\n(`apps\u002Fmcp-server\u002Fsrc\u002Fui\u002Fview-registry.ts`, which spreads `engineViews` from\n`src\u002Fwidgets\u002Findex.ts`) to the view-bound tools of the tools\u002Flist goldens.\n\n`src\u002Fwidget-tools.ts` wires the cockpit tool (`src\u002Fwidget-tools\u002Fcockpit.ts`), the `*_data`\nfeeds (`data-feeds.ts`, over `DATA_FEEDS`) and the actions feed; these files are the documented\nexception that calls the official SDK's `server.registerTool(name, config, handler)` directly\n(`server` is the module's `ModuleServer`; its handler gets `(args, ctx)`, the SDK's context with the\ncall's abort signal at `ctx.mcpReq.signal`):\n\n- Every widget-path tool (the cockpit AND each feed) declares\n  `inputSchema: strictToolInput({ … })` (from `@miragon\u002Fpulse-widget-shell\u002Fserver`), never\n  a bare `z.object`, which strips an unknown key silently;\n  `apps\u002Fmcp-server\u002Ftest\u002Fstrict-input.e2e.test.ts` refuses any module tool that\n  accepts one.\n- `engine_open_cockpit` spreads `...showToolBinding(ENGINE_OPEN_COCKPIT, \"Open Cockpit\")`\n  (from `@miragon\u002Fpulse-widget-shell\u002Fserver`) into its config, which binds the view named after\n  the tool (`_meta.ui.resourceUri: \"ui:\u002F\u002Fviews\u002F\u003Ctool>.html\"`, whose resource the boot serves),\n  adds the required passthrough `outputSchema` and stamps the Apps-SDK half of `_meta`;\n  **never write `_meta` keys by hand**. Resolve the engine via\n  `resolveEngine(args.engine, registry, ctx)`, which already returns `baseUrl`\u002F`cockpitUrl`;\n  never fish them out of `registry.engines`. It returns `buildViewResult(data, facts)`\n  (from `@miragon\u002Fpulse-widget-shell\u002Fserver`): `data` IS the view's data, no title (the\n  widget's heading names the view); the facts are the compact JSON of the route's `facts`, the\n  model-facing text channel, while the full payload travels only in `structuredContent`.\n- `*_data` feeds: describe one with `dataFeed({ name, title, description, shape, read })` in\n  `DATA_FEEDS` (`src\u002Fdata\u002Ffeeds.ts`); `registerDataFeed` in `data-feeds.ts` registers each and\n  spreads `...appOnly` (SEP-1865 `_meta.ui.visibility: [\"app\"]` plus\n  `openai\u002FwidgetAccessible`; **no** view binding) and returns `buildDataFeedResult(data)` so\n  the in-widget `callTool()` gets JSON back instead of the host rendering a new widget.\n  Wrap every raw handler in `withToolErrors` (from `@miragon\u002Fpulse-widget-shell\u002Fserver`).\n- Shared data paths: every number a view shows comes from ONE builder in `src\u002Fdata\u002F`\n  that its `*_data` feed AND the cockpit's start view (`COCKPIT_ROUTES`) call; never\n  fork one. Definition lookups (id parsers, the key lookup, the per-KEY fold of the\n  per-version statistics) come from `src\u002Fdata\u002Fdefinition-info.ts`. A key's latest version is\n  `fetchLatestDefinition`\n  (every tenant), never `\u002Fprocess-definition\u002Fkey\u002F{key}`, which only knows tenant-less\n  definitions. A long key's (over ~25 characters) definition id is a bare generated\n  UUID that names no key, so ANY key that scopes a query, a drill, a hand-off or a\n  cockpit link is resolved, never parsed: a view queried by key uses that key; rows\n  that carry only a definition id go through `resolveDefinitionKeys` (at most ONE\n  definition-statistics read, none when every id names its key): a primary read where\n  the key is what the view hands on, else wrapped in `optional(...)` as enrichment, so\n  a bare id's key is `null` (no drill, no link), never its UUID. A view that already\n  reads the statistics resolves through `definitionKeyResolver` over them.\n  `processDefinitionKeyFromId` is a display fallback only.\n- **Honest numbers** (`src\u002Fdata\u002Fengine-reads.ts`): PRIMARY rows and counts (what the\n  view exists to show, and every number its facts report) propagate engine\n  failures (read them through `countOf`\u002F`rowsOf`; `withToolErrors` turns the throw into\n  a tool error), and an unknown id or key is a not-found error, never a view of zeros. Only\n  ENRICHMENT (display names, diagram XML, cockpit links, optional history) degrades, via\n  `optional(...)`, and to `null`, never to a `0`, `[]` or `{}` that reads as a fact.\n  Widgets render that null as \"—\" (`formatCount` from `@miragon\u002Fpulse-ui`),\n  the facts keep it `null` (the instructions read it as unknown). Totals come from `\u002Fcount`\n  endpoints or statistics, never from\n  the length of a capped page or scan: a recency scan (`INCIDENT_SCAN_LIMIT`, the health\n  views' `CLUSTER_SCAN_LIMIT`) only enriches and says how far it reaches\n  (`scannedIncidentCount`, null facts, \"≥ N\" via `formatCount(exact ?? { atLeast: scanned })`). A view about\n  a process definition KEY spans every deployed version (key-scoped `\u002Fcount`, statistics\n  summed per key); only a diagram is one version, and it is labelled. Engine timestamps\n  compare by instant (`engineDateMillis`\u002F`latestEngineDate`\u002F`earliestEngineDate` from\n  `@miragon\u002Fpulse-c7-core`), never as strings. A new builder adds its case to the\n  rejection table in `src\u002Fdata\u002Fhonest-numbers.test.ts`; the table fails without it. The\n  table breaks EVERY read the healthy build makes (method + path + query names), so a\n  case declares only its enrichment reads; any other read whose failure is swallowed fails.\n- The `show_*`\u002F`*_data` naming is load-bearing:\n  `apps\u002Fmcp-server\u002Ftest\u002Fwidget-contract.e2e.test.ts` enforces the widget `_meta` on\n  every `*_show_*` tool and on `engine_open_cockpit`, and app-only visibility on every\n  `*_data` feed **by name**.\n- A widget-path tool never writes. A write is a registrar tool, so the toolset filter\n  (`withToolsetFilter`\u002F`isToolInToolset`) drops it outside its toolset, and an in-widget\n  write calls it through `useEngineAction`, gated by `engine_widget_actions_data`.\n- Every in-widget write runs through `useEngineAction({ tool, target, resetOn, available })`\n  (`src\u002Fwidgets\u002Flib\u002Fengine-action.ts`), never a raw `useToolMutation`\u002F`callTool`. Its\n  `allowed` is the gate, the toolset AND the subject's current state (`available`:\n  false for an ended, cancelled or unconfirmed instance, a suspended one where the\n  engine refuses the write): render the control only when true (hide it and a table\n  column it would leave empty, never just disable it). List\n  the tool in `ENGINE_WIDGET_ACTIONS` (`src\u002Ftool-names.ts`) and give it a\n  `WRITE_POLICY` entry (`src\u002Fwidgets\u002Flib\u002Fwrite-policy.ts`: the query namespaces it\n  changes, and `confirm: true` for irreversible\u002Fstate-changing writes; then `run`\n  needs a confirmation naming its target, rendered by `EngineActionDialog`, with\n  verb + object on both buttons: widget-shell's `ConfirmDialog`, an `alertdialog` over the\n  scrim, the destructive confirm in the danger role, a failure shown inside it). Every\n  `run` passes `feedback`, the toasts' titles naming the object, from\n  `src\u002Fwidgets\u002Flib\u002Fwrite-feedback.ts` (`writeFeedback.jobRetry(t, instanceName(id,\nbusinessKey))` …; add an entry and its `feedback.*` keys in `messages\u002F{en,de}.feedback.ts`\n  for a new write): a success toast, a failure toast with the reason in words\n  (`useActionFeedback`); a confirmed write's failure stays in its dialog, a read-only\n  engine's refusal at its control, without a toast. Optimistic state reads `action.done` (dropped when\n  `resetOn` changes; pass the data the marked ROWS come from, not a sibling feed).\n  `src\u002Fwidget-actions.test.ts` enforces all of it structurally; add\n  the new call site to `src\u002Fwidgets\u002Faction-gating.sites.ts` + its renderer in\n  `action-gating.test-support.tsx`.\n- Everything the widget tells the model goes through `useHandOff()`\n  (`src\u002Fwidgets\u002Flib\u002Fhand-off.ts`): `\u003CHandOffButton action=\"…\" prompt={ask(handOff)} \u002F>` and\n  `\u003CHostModelContext content={context(view)}>`. A hand-off is a typed spec: an `intent`\n  key from `messages\u002F{en,de}.ask-ai.ts` (static, no tool names) or a static view\n  `summary`, plus `ids`, `facts`, `untrusted` (engine text, fenced); no `tools`: a hand-off\n  names no tool, the module instructions and the tool descriptions steer the model.\n  Reference:\n  `src\u002Fwidgets\u002Fremediation.ts`; the rules are CLAUDE.md invariant 6, enforced by\n  `scripts\u002Fhand-off-refs.test.mjs`.\n\n## Step 5: verify\n\n```bash\n# while you iterate: the test files you touch (coverage off, the packages under them built first)\npnpm test:one packages\u002Fconnectors\u002Fengine\u002Fengine-connector\u002Fsrc\u002Ftools\u002F\u003Cdomain>.wire.test.ts\n# then the package and every package that depends on it (the server's goldens included)\npnpm exec turbo run typecheck test lint --filter=...@miragon\u002Fpulse-engine-connector\n# the bar for every change: build, typecheck, test, lint, format\npnpm check\n# for widget\u002Fshell changes additionally:\npnpm --filter @miragon\u002Fpulse-mcp-server test:host\n```\n\nA file run alone fails the package's coverage floors, since it loads only part of the code, so\nuse `pnpm test:one` for it; `pnpm test` keeps enforcing the floors.\n\nA new or changed tool changes the wire surface: add its name to\n`apps\u002Fmcp-server\u002Ftest\u002Fexpected-tools.ts`, then refresh the per-toolset goldens with\n`pnpm --fail-if-no-match --filter @miragon\u002Fpulse-mcp-server golden:update` (it builds the\nconnectors first) and commit the\n`test\u002F__golden__\u002F` diff (descriptions and schemas are what the model reads; review them).\nThe budget counts the model-visible tools\u002Flist text plus the server instructions of each\ntoolset. If `char-budgets.json` grew, `pnpm lint` fails until the commit carries a\n`Ratchet-Exception: \u003Cwhy the model must read more>` trailer. Trim wording first.\n\n`pnpm typecheck` is the **only** automated check that type-checks widget `.tsx` code\n(`tsc -p tsconfig.widgets.json`); never skip it. For widgets also do a manual render\ncheck: `pnpm play` (playground, build and server in one command), then call the\ntool in the MCP Inspector `pnpm play` starts next to the server (it prints the URL). The default boot\nis read-only (the boot log names the toolsets). To exercise a write tool or button, run\n`pnpm play --admin` (`--deploy` for deployments). Run `pnpm format:check` (or\n`pnpm format`) before committing.\n",{"name":5,"description":7,"allowed-tools":49},"Read, Edit, Write, Glob, Grep, Bash","main","ff711bb6e1eb952767e56e3bdd4d7c249ced5b68",".claude\u002Fskills\u002Fadd-bpm-feature",0,"https:\u002F\u002Fgithub.com\u002FMiragon\u002Fpulse","https:\u002F\u002Fmiragon.github.io\u002Fpulse\u002F","https:\u002F\u002Fgithub.com\u002FMiragon","https:\u002F\u002Fwww.miragon.io","We enable organizations to become truly digital.","2026-11-14 03:34:19",{"source":8,"skillName":5,"commands":61,"prompt":71,"zipAvailable":72},[62,65,68],{"tool":63,"command":64},"npx","npx skills add Miragon\u002Fpulse --skill add-bpm-feature",{"tool":66,"command":67},"bunx","bunx skills add Miragon\u002Fpulse --skill add-bpm-feature",{"tool":69,"command":70},"pnpm","pnpm dlx skills add Miragon\u002Fpulse --skill add-bpm-feature","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002FMiragon\u002Fpulse\u002Fblob\u002Fmain\u002F.claude\u002Fskills\u002Fadd-bpm-feature\u002FSKILL.md",true,[],[75,107,116,141,166,191],{"skillNo":76,"name":77,"nameZh":6,"description":78,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":9,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":79,"categories":80,"keywords":88,"publishedAt":44},"01M6ENA6VEQJXWWXWE52B9JPZK","setup-server","Set up, configure, run, brand, and deploy this composed MCP server. Use whenever connecting a Camunda 7 \u002F CIB seven engine, selecting modules or toolsets (`MCP_ACTIVE_MODULES`), renaming the `@acme` scope, building the Docker image, running behind an MCP gateway, or troubleshooting boot warnings or inspector issues.","https:\u002F\u002Fgithub.com\u002FMiragon\u002Fpulse\u002Fblob\u002Fmain\u002Ftemplates\u002Fcomposed-server\u002F.claude\u002Fskills\u002Fsetup-server\u002FSKILL.md",[81,84,87],{"categoryNo":82,"categoryName":83,"taxonomyType":20,"taxonomyTypeDesc":21},"01JBSEEDCAT000000000000010","后端与接口",{"categoryNo":85,"categoryName":86,"taxonomyType":20,"taxonomyTypeDesc":21},"01JBSEEDCAT000000000000011","运维与部署",{"categoryNo":18,"categoryName":19,"taxonomyType":20,"taxonomyTypeDesc":21},[89,90,91,92,93,94,95,96,97,98,99,100,42,101,102,103,104,41,105,106],"setup server","server","setup","modules","SET","gateway","prometheus","brand","scope","docker","up","issues","use","boot","configure","image","mcp server","running",{"skillNo":108,"name":109,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":9,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":110,"categories":111,"keywords":112,"publishedAt":44},"01M6ENA321FCXRTT8X06D0K4A0","create-module","https:\u002F\u002Fgithub.com\u002FMiragon\u002Fpulse\u002Fblob\u002Fmain\u002Ftemplates\u002Fcomposed-server\u002F.claude\u002Fskills\u002Fcreate-module\u002FSKILL.md",[],[113,114,115],"create module","module","create",{"skillNo":117,"name":118,"nameZh":6,"description":119,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":9,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":120,"categories":121,"keywords":125,"publishedAt":44},"01M6EN9ZEDJZMEAJH3RP50NV5S","add-widget","Step-by-step house pattern for adding an interactive widget to a module (the React component built from the design kit `@miragon\u002Fpulse-ui` and widget-shell's view runtime, the `show_*` widget tool, the app-only `*_data` feed, the view map keyed by the show tool's name, and a widget test under `TestViewHost`). Use whenever adding or changing widgets, `show_*` tools, `*_data` feeds, in-widget data fetching, or when debugging a widget that renders empty, unstyled, or hangs on \"Loading…\".","https:\u002F\u002Fgithub.com\u002FMiragon\u002Fpulse\u002Fblob\u002Fmain\u002Ftemplates\u002Fcomposed-server\u002F.claude\u002Fskills\u002Fadd-widget\u002FSKILL.md",[122],{"categoryNo":123,"categoryName":124,"taxonomyType":20,"taxonomyTypeDesc":21},"01JBSEEDCAT000000000000009","前端开发",[126,127,24,128,43,129,130,114,131,101,132,41,133,134,135,136,137,138,139,140],"add widget","widget","app","show","tool","only","debugging","view","for","loading","runtime","design","kit","map","when",{"skillNo":142,"name":143,"nameZh":6,"description":144,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":9,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":145,"categories":146,"keywords":147,"publishedAt":44},"01M6EN9PRZFNEA99X41BWPYVAB","pulse-cli","Inspect and operate Camunda 7, Operaton and CIB seven process engines through their REST API with the Pulse CLI (deployments, process instances, user tasks, external tasks, incidents, jobs, history, variables, decisions), and drive process instances with its workflow commands (inspect, wait, advance, retry, deploy, status). Use it whenever a task needs to read or change the state of such an engine.","https:\u002F\u002Fgithub.com\u002FMiragon\u002Fpulse\u002Fblob\u002Fmain\u002Fapps\u002Fcli\u002Fskills\u002Fpulse-cli\u002FSKILL.md",[],[148,149,150,151,152,153,154,155,156,40,157,158,159,160,161,162,35,163,164,165],"pulse cli","pulse","cli","instances","history","advance","deployments","workflow","tasks","operate","retry","inspect","incidents","Variables","change","commands","status","wait",{"skillNo":167,"name":168,"nameZh":6,"description":169,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":9,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":170,"categories":171,"keywords":172,"publishedAt":44},"01M6EN9J8S9R51AP99F0YBBQM7","shadcn","Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn\u002Fui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for \"shadcn init\", \"create an app with --preset\", or \"switch to --preset\".","https:\u002F\u002Fgithub.com\u002FMiragon\u002Fpulse\u002Fblob\u002Fmain\u002F.claude\u002Fskills\u002Fshadcn\u002FSKILL.md",[],[168,128,173,174,175,176,177,178,179,180,132,181,182,183,184,115,185,186,187,188,189,134,140,190],"projects","component docs","switch","project","init","triggers","codes","false","file","components","examples","preset","component","json","project context","styling","usage","with",{"skillNo":192,"name":193,"nameZh":6,"description":194,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":9,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":195,"categories":196,"keywords":199,"publishedAt":44},"01M6EN9ETKR0TQJRVBVEHW17VM","mcp-apps-builder","Where to look before changing MCP server code in this repo (tools, resources, prompts, widgets, auth). Points to the house skills, the CLAUDE.md invariants, widget-shell's view runtime and the official MCP SDK's typings and docs; it teaches no framework API itself.","https:\u002F\u002Fgithub.com\u002FMiragon\u002Fpulse\u002Fblob\u002Fmain\u002F.claude\u002Fskills\u002Fmcp-apps-builder\u002FSKILL.md",[197,198],{"categoryNo":82,"categoryName":83,"taxonomyType":20,"taxonomyTypeDesc":21},{"categoryNo":18,"categoryName":19,"taxonomyType":20,"taxonomyTypeDesc":21},[200,42,201,202,203,90,204,36,205,206,207,101,105,208,133,136,209,210,127,40,211,212,213],"builder","apps","mcp apps builder","mcp apps","resources","auth","repo","look","code","shell","invariants","official","mcp sdk","sdk"]