report-gap

report-gap reports that Pluma fell short of what you needed: a missing component, variant or prop, a layout or styling gap, an accessibility or docs gap, or an API that got in the way. It sends the report to the handlers your project lists in pluma.config.mjs, so the gap reaches the people who can close it instead of turning up later as a workaround.

When to use it

Run it the moment Pluma falls short of what you need, before working around the gap. Don't use it for a bug in a component that exists and should already do what you need; file that as an issue against Pluma instead. Agents send the same report through the report_gap tool of the Pluma MCP server (see MCP twin).

Usage

pluma-cli report-gap --category <category> --intention <text>
                     [--detail <text>] [--component <id>] [--file <path>]
                     [--source <source>] [--confirm-public] [--json]
pluma-cli report-gap --list-categories [--json]

Flags

table
FlagDescription
--category <c>Required. One of missing_component, missing_variant, missing_prop, layout_gap, styling_gap, a11y_gap, api_friction, docs_gap, other.
--intention <t>Required. What you were trying to do, up to 2000 characters.
--detail <t>What you tried or built instead, up to 8000 characters.
--component <id>The kebab-case id of the Pluma component the gap is about, e.g. date-picker.
--file <path>The file you were working in. An absolute path picks the repository that holds it, and is recorded relative to its root.
--source <s>Who is reporting: human (the default), ai or automation.
--confirm-publicLet handlers whose audience is public run. Without it they send nothing and are listed as consent_required.
--jsonPrint the receipt (or, with --list-categories, the categories) as JSON.
--list-categoriesPrint the categories, one per line.
-h, --helpShow help.

Each flag also takes the --name=value form, which a value that starts with -- needs: --detail=--force broke the layout. -h and --help show help only where a flag can go, so --intention -h records -h. Invalid input or an unusable pluma.config.mjs exits with status 2 and delivers nothing; a failed delivery, or a report no handler took, exits with status 1.

Examples

List the categories

pluma-cli report-gap --list-categories
missing_component
missing_variant
missing_prop
layout_gap
styling_gap
a11y_gap
api_friction
docs_gap
other

Report a gap

With a pluma.config.mjs that sends reports to jsonlFile({ path: '.pluma/team-gaps.jsonl' }), the report is appended to that file and the receipt lists one filed delivery:

pluma-cli report-gap --category missing_variant --component button   --file src/billing/cancel.tsx   --intention "Show a destructive tertiary action in the cancel flow."   --detail "Used a plain link styled red."
Gap report: filed
  #0 jsonlFile (internal): filed file:///path/to/app/.pluma/team-gaps.jsonl
    Appended to /path/to/app/.pluma/team-gaps.jsonl.

The same report with --json prints the receipt as JSON:

pluma-cli report-gap --category missing_variant --component button   --file src/billing/cancel.tsx   --intention "Show a destructive tertiary action in the cancel flow."   --detail "Used a plain link styled red."   --json
{
  "status": "filed",
  "deliveries": [
    {
      "handler": 0,
      "name": "jsonlFile",
      "audience": "internal",
      "status": "filed",
      "url": "file:///path/to/app/.pluma/team-gaps.jsonl",
      "message": "Appended to /path/to/app/.pluma/team-gaps.jsonl."
    }
  ]
}

Report a gap with no handler configured

Without a pluma.config.mjs, the report goes to the fallback file, .pluma/gap-reports.jsonl. When the installed @customerio/pluma-components names an issue tracker, the receipt says to file the gap there. Run where the package isn't installed, as here, the delivery is filed:

pluma-cli report-gap --category missing_variant --component button   --file src/billing/cancel.tsx   --intention "Show a destructive tertiary action in the cancel flow."   --detail "Used a plain link styled red."
Gap report: filed
  fallback (internal): filed file:///path/to/app/.pluma/gap-reports.jsonl
    No gap report handler is configured. Saved to /path/to/app/.pluma/gap-reports.jsonl.

JSON output

--list-categories --json prints the categories as an array of strings.

The report

Every handler receives the same report. This is the line jsonlFile appended for the example in Report a gap:

{
	"schemaVersion": 1,
	"component": "button",
	"category": "missing_variant",
	"intention": "Show a destructive tertiary action in the cancel flow.",
	"detail": "Used a plain link styled red.",
	"source": "human",
	"file": "src/billing/cancel.tsx",
	"timestamp": "<timestamp>",
	"target": {
		"package": "@customerio/pluma-components",
		"version": null,
		"issuesUrl": null
	}
}

component, detail and file appear only when given. target.version is the @customerio/pluma-components installed in the node_modules of the working directory (or of the directory holding an absolute file) or a directory above it, and target.issuesUrl is the bugs URL in that package's package.json. Either is null when it can't be found, as above, where the example ran in a directory without the package installed.

The receipt

report-gap prints a receipt: one delivery per handler, in order, and an overall status. --json prints it as JSON:

{
	"status": "filed",
	"deliveries": [
		{
			"handler": 0,
			"name": "jsonlFile",
			"audience": "internal",
			"status": "filed",
			"url": "file:///repo/.pluma/team-gaps.jsonl"
		},
		{ "handler": 1, "name": "githubIssue", "audience": "public", "status": "consent_required", "message": "..." }
	]
}

A delivery's status is what its handler returned (filed, routed_only or skipped), failed when the handler threw, returned something invalid or ran past its 30 seconds, or consent_required for a public handler run without --confirm-public. The overall status is:

  1. partial when a delivery failed and another was filed or routed_only, and failed when a delivery failed and none was;
  2. otherwise filed when any delivery was filed;
  3. otherwise routed_only when any was routed_only;
  4. otherwise consent_required when any was;
  5. otherwise failed: every handler skipped the report.

With no handler configured, the report is appended to .pluma/gap-reports.jsonl and the receipt has one delivery with handler: "fallback". It is routed_only with the package's issuesUrl, so you know where to file the gap, or filed with the file's URL when the package names no issue tracker.

Handlers

A handler is an object with an audience and a handle function:

interface GapReportHandler {
	name?: string;
	audience: 'internal' | 'public';
	handle(
		report: GapReport,
		context: { signal: AbortSignal },
	): Promise<{
		status: 'filed' | 'routed_only' | 'skipped';
		url?: string;
		message?: string;
	}>;
}
  • audience: 'internal' handlers keep the report within your team and always run. audience: 'public' handlers send it somewhere anyone can read, and run only with --confirm-public (or confirmPublic: true from the MCP tool).
  • handle gets its own copy of the report, and a signal that aborts after 30 seconds. A handler that throws or runs out of time is a failed delivery, and the next handler still runs.
  • Return filed when the handler stored the report, routed_only with a url when it only knows where the report should go, and skipped to pass on it.

@customerio/pluma-cli/gap-report ships four handlers:

  • jsonlFile({ path }) appends each report as one JSON line to path, resolved against the working directory, and returns filed.
  • githubIssue({ repo, labels, audience }) files each report as an issue in repo (owner/name) with the gh command, which must be installed and signed in. The title is [gap] <category>: <component> (the start of the intention when there is no component) and the labels are gap-report, gap:<category>, source:<source> and any labels you add; they must exist in the repository. When an open issue has the same labels and title, the report becomes a comment on it instead. It returns filed with the issue's URL.
  • linearIssue({ teamId, labelIds, apiKeyEnv, audience, apiUrl }) files each report as an issue in the Linear team teamId through Linear's GraphQL API. The title and description match githubIssue's, and labelIds are the ids of labels that exist in the team. When an open issue in the team has the same title, the report becomes a comment on it instead. It returns filed with the issue's URL. The API key is read from the LINEAR_API_KEY environment variable, or the one apiKeyEnv names. audience defaults to internal, and apiUrl to https://api.linear.app/graphql.
  • slackMessage({ webhookUrlEnv, audience }) posts each report to a Slack channel through an incoming webhook: a header with the category and component, the intention and detail, and the file, source and package. The webhook URL is read from the SLACK_GAP_REPORT_WEBHOOK_URL environment variable, or the one webhookUrlEnv names. audience defaults to internal. It returns filed with no URL.

linearIssue and slackMessage read their secrets only from the environment, when a report is delivered, so the API key and the webhook URL never go in pluma.config.mjs. A missing variable, an error from the service, or a response that isn't a success is a failed delivery. Both handlers accept a fetch option that replaces the global fetch, for tests.

// pluma.config.mjs
import { linearIssue, slackMessage } from '@customerio/pluma-cli/gap-report';

/** @type {import('@customerio/pluma-cli/gap-report').PlumaConfig} */
export default {
	version: 1,
	gapReport: [
		// Needs LINEAR_API_KEY in the environment.
		linearIssue({ teamId: 'your-team-id', labelIds: ['your-gap-label-id'] }),
		// Needs SLACK_GAP_REPORT_WEBHOOK_URL in the environment.
		slackMessage(),
	],
};

The package also exports the report types, GAP_CATEGORIES, and reportGap(input, options), which builds a report and delivers it the way report-gap does.

MCP twin

The report_gap tool of the Pluma MCP server sends the same report through the same handlers in pluma.config.mjs, read from the server's working directory, and returns the same receipt.

table
CLI flagreport_gap parameter
--categorycategory
--intentionintention
--detaildetail
--componentcomponent
--filefile. Agents should pass an absolute path.
--sourcesource. Defaults to ai instead of human.
--confirm-publicconfirmPublic: true. An agent passes it only after the user agreed to share the report publicly.
--jsonNone: the tool always returns the receipt as JSON.
--list-categoriesNone: the tool's category parameter lists the categories.

Invalid input or an unusable pluma.config.mjs comes back as a tool error instead of an exit status.

Troubleshooting

No handler is configured

Gap report: routed_only
  fallback (internal): routed_only https://github.com/customerio/pluma/issues
    No gap report handler is configured. Saved to /repo/.pluma/gap-reports.jsonl; file it at https://github.com/customerio/pluma/issues.

There is no pluma.config.mjs in the working directory, or it has no gapReport key, so the report went to .pluma/gap-reports.jsonl at the repository root. The delivery is routed_only when the installed @customerio/pluma-components names an issue tracker: file the gap there by hand. It is filed when the package names none, or isn't installed. To send reports somewhere your team reads, add a gapReport handler to pluma.config.mjs and run report-gap from the directory that holds it.

githubIssue fails

Gap report: failed
  #0 githubIssue (internal): failed
    spawn gh ENOENT

githubIssue files issues with the gh command. spawn gh ENOENT means gh isn't installed or isn't on your PATH; a message that starts with gh failed: is gh's own error, such as not being signed in, no access to the repository, or a label that doesn't exist there. The delivery is failed and the command exits with status 1. Install gh and run gh auth login, check that the account can open issues in the repository, and create the labels the handler applies: gap-report, gap:<category>, source:<source> and any labels you added.

A public handler sent nothing

Gap report: consent_required
  #0 githubIssue (public): consent_required
    Sends the report outside the team. Run again with consent to deliver it.

A handler with audience: 'public' sends the report somewhere anyone can read, so it runs only with --confirm-public. Without it the delivery is consent_required and nothing is sent. Run the command again with --confirm-public once you are sure the report can be public. Other handlers in the list still run either way.

Invalid input or an unusable config

Unknown category "missing-variant". Use one of: missing_component, missing_variant, missing_prop, layout_gap, styling_gap, a11y_gap, api_friction, docs_gap, other.

The command exits with status 2 and delivers nothing, to the handlers or the fallback file. It prints every problem with the input at once: a missing or unknown category, a missing or too long intention, a too long detail, a component id that isn't kebab-case, or an unknown source. Run pluma-cli report-gap --list-categories for the categories.

The same status follows a pluma.config.mjs that can't be used. The message starts with the file's path and says what is wrong: it could not be loaded (a syntax error, a missing import, or a handler such as githubIssue given a repo that isn't owner/name), it needs a default export object, its version is not one this release reads, or a gapReport entry needs a handle(report, { signal }) function or an audience of "internal" or "public".