@getsnare/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # @getsnare/mcp
2
+
3
+ Snare's MCP server. Point any MCP client at it to read and act on issues, events, traces and fix runs — or to run a Snare on your own machine, with your own model.
4
+
5
+ ## Install
6
+
7
+ You need a Snare token. Make one at [snare.dev/settings/api-tokens](https://snare.dev/settings/api-tokens).
8
+
9
+ ### Claude Code
10
+
11
+ ```bash
12
+ claude mcp add snare --env SNARE_API_TOKEN=snr_... -- npx -y @getsnare/mcp
13
+ ```
14
+
15
+ ### Cursor, Windsurf, Codex, opencode
16
+
17
+ Add this to the client's MCP config file:
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "snare": {
23
+ "command": "npx",
24
+ "args": ["-y", "@getsnare/mcp"],
25
+ "env": { "SNARE_API_TOKEN": "snr_..." }
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ ### Over HTTP
32
+
33
+ For clients that take a URL instead of a command:
34
+
35
+ ```
36
+ https://snare.dev/api/mcp
37
+ ```
38
+
39
+ Send the token as `Authorization: Bearer snr_...`.
40
+
41
+ ## What it can do
42
+
43
+ | Set | Tools |
44
+ | --- | --- |
45
+ | `issues` | List, search, read, comment, assign, change status and severity, merge, split, see who is in the workspace |
46
+ | `events` | Occurrences with their stacks, traces, session recordings, affected users |
47
+ | `snares` | Launch a fix run, watch it, answer its questions, steer it, stop it, read its diff, check what is left on the plan |
48
+ | `local` | Run a Snare here, on your own agent |
49
+ | `feedback` | Bug reports and feature requests from your users |
50
+ | `memory` | Project rules, standing instructions, what Snare has learned |
51
+ | `workspace` | Projects and how the workspace is doing |
52
+ | `setup` | Install the SDK |
53
+
54
+ All of them are on by default. Narrow it with `SNARE_MCP_TOOLSETS` when you want a smaller catalogue:
55
+
56
+ ```
57
+ SNARE_MCP_TOOLSETS=issues,events
58
+ SNARE_MCP_TOOLSETS=all
59
+ ```
60
+
61
+ Every tool's description is text the model reads on every turn, so narrowing leaves more room for the problem you are working on. Turning a set off means the model cannot see it at all — it will say Snare cannot do that, rather than that you switched it off.
62
+
63
+ ## Permissions
64
+
65
+ A token carries scopes, and the server only registers tools that token can use. A read-only token sees read-only tools; the rest are not offered at all, so the model never picks one it will be refused for.
66
+
67
+ Two tools spend a Snare from your plan: `launch_snare` and `start_local_snare`. The token page marks both scopes.
68
+
69
+ Pick scopes when you make the token. **Read only** suits an agent that answers questions about production. **Triage** adds commenting, assigning and status changes without spending anything.
70
+
71
+ ## Running a Snare locally
72
+
73
+ `start_local_snare` runs Snare's fix loop against the repository you have open, using your own model.
74
+
75
+ Snare decides the stages, checks each answer against that stage's contract, scores the result and meters the run. Your agent does the reading and the editing. It is the same loop as a cloud run — the model and the machine are yours.
76
+
77
+ Your agent calls `start_local_snare`, then `local_snare_step` in a loop until the run reports `done`. Each instruction says what to do and what to send back.
78
+
79
+ Three things to know before you start one:
80
+
81
+ - **It changes files in your working tree.** Commit or stash first.
82
+ - **It spends a Snare** from your plan, the same as a cloud run.
83
+ - **Snare cannot enforce its safety rules on your machine.** In its own sandbox they block a command before it runs. Here they are instructions to your agent. Snare will not ask your agent to do something it would have blocked, but it cannot stop your agent doing it anyway.
84
+
85
+ Snare Lite and investigations do not run locally yet. Use `launch_snare` for those.
86
+
87
+ ## Configuration
88
+
89
+ | Variable | Default |
90
+ | --- | --- |
91
+ | `SNARE_API_TOKEN` | required |
92
+ | `SNARE_MCP_TOOLSETS` | `all` |
93
+ | `SNARE_BASE_URL` | `https://snare.dev/api/v1` |
94
+ | `SNARE_DASHBOARD_URL` | derived from `SNARE_BASE_URL` |
95
+
96
+ Each has a command-line equivalent (`--token`, `--toolsets`, `--base-url`, `--dashboard-url`) which takes precedence.
97
+
98
+ ## Licence
99
+
100
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { buildConfig, ConfigError } from "../src/config.js";
4
+ import { createSnareMcpServer } from "../src/server.js";
5
+ /**
6
+ * The stdio server.
7
+ *
8
+ * NOTHING MAY BE WRITTEN TO STDOUT EXCEPT PROTOCOL TRAFFIC. A single stray
9
+ * `console.log` corrupts the JSON-RPC stream, and the failure surfaces in the
10
+ * client as "the server disconnected" with no indication of why. Every message
11
+ * here goes to stderr, which clients show in their log panel.
12
+ *
13
+ * A CONFIG PROBLEM EXITS WITH ONE SENTENCE, not a stack. This is the first
14
+ * thing a new user hits, usually inside a client that shows them one line, and
15
+ * "SNARE_API_TOKEN is not set — get one at ..." is worth more than forty lines
16
+ * of frames.
17
+ */
18
+ function readArg(name) {
19
+ const prefix = `--${name}=`;
20
+ const inline = process.argv.find((arg) => arg.startsWith(prefix));
21
+ if (inline)
22
+ return inline.slice(prefix.length);
23
+ const index = process.argv.indexOf(`--${name}`);
24
+ return index >= 0 ? process.argv[index + 1] : undefined;
25
+ }
26
+ async function main() {
27
+ // Argv beats env, so a client that pins a token in its own config file wins
28
+ // over whatever happens to be in the shell that launched it.
29
+ const config = buildConfig({
30
+ token: readArg("token") ?? process.env.SNARE_API_TOKEN,
31
+ baseUrl: readArg("base-url") ?? process.env.SNARE_BASE_URL,
32
+ dashboardUrl: readArg("dashboard-url") ?? process.env.SNARE_DASHBOARD_URL,
33
+ toolsets: readArg("toolsets") ?? process.env.SNARE_MCP_TOOLSETS,
34
+ });
35
+ const { server, identity } = await createSnareMcpServer(config);
36
+ // stderr, always. See the header.
37
+ console.error(`snare-mcp ready${identity.organization ? ` for ${identity.organization.name}` : ""} ` +
38
+ `(${identity.scopes.length} scopes, sets: ${config.toolsets.join(", ")})`);
39
+ await server.connect(new StdioServerTransport());
40
+ }
41
+ main().catch((err) => {
42
+ if (err instanceof ConfigError) {
43
+ console.error(err.message);
44
+ process.exit(1);
45
+ }
46
+ console.error(err instanceof Error ? err.message : String(err));
47
+ process.exit(1);
48
+ });
@@ -0,0 +1,37 @@
1
+ import type { McpConfig } from "./config.js";
2
+ /**
3
+ * The only way this package talks to Snare.
4
+ *
5
+ * NO DATABASE, EVER. This server runs on a customer's machine. A database
6
+ * driver in this process would mean shipping a production connection string to
7
+ * everybody who runs `npx`, and the trust boundary would be "whatever the code
8
+ * remembers not to select" rather than a token with scopes on it. Everything
9
+ * goes through `/api/v1`, which is the same door the dashboard, the mobile app
10
+ * and any CI script use.
11
+ *
12
+ * WHICH ALSO MEANS THE TWO CANNOT DRIFT. There is no second implementation of
13
+ * "what an issue looks like" to keep in step with the API's, because there is
14
+ * no second implementation.
15
+ */
16
+ /** A refusal from the API, carrying the machine-readable code the routes return. */
17
+ export declare class SnareApiError extends Error {
18
+ readonly status: number;
19
+ readonly code: string;
20
+ constructor(status: number, code: string, message: string);
21
+ }
22
+ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
23
+ export declare class SnareClient {
24
+ private readonly config;
25
+ private readonly fetchImpl;
26
+ constructor(config: McpConfig, fetchImpl?: FetchLike);
27
+ get dashboardUrl(): string;
28
+ get<T>(path: string, query?: Record<string, string | number | boolean | undefined>): Promise<T>;
29
+ post<T>(path: string, body?: unknown, options?: {
30
+ timeoutMs?: number;
31
+ retry?: boolean;
32
+ }): Promise<T>;
33
+ patch<T>(path: string, body?: unknown): Promise<T>;
34
+ put<T>(path: string, body?: unknown): Promise<T>;
35
+ delete<T>(path: string, body?: unknown): Promise<T>;
36
+ private request;
37
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The only way this package talks to Snare.
3
+ *
4
+ * NO DATABASE, EVER. This server runs on a customer's machine. A database
5
+ * driver in this process would mean shipping a production connection string to
6
+ * everybody who runs `npx`, and the trust boundary would be "whatever the code
7
+ * remembers not to select" rather than a token with scopes on it. Everything
8
+ * goes through `/api/v1`, which is the same door the dashboard, the mobile app
9
+ * and any CI script use.
10
+ *
11
+ * WHICH ALSO MEANS THE TWO CANNOT DRIFT. There is no second implementation of
12
+ * "what an issue looks like" to keep in step with the API's, because there is
13
+ * no second implementation.
14
+ */
15
+ /** A refusal from the API, carrying the machine-readable code the routes return. */
16
+ export class SnareApiError extends Error {
17
+ status;
18
+ code;
19
+ constructor(status, code, message) {
20
+ super(message);
21
+ this.status = status;
22
+ this.code = code;
23
+ this.name = "SnareApiError";
24
+ }
25
+ }
26
+ /**
27
+ * Statuses worth trying again.
28
+ *
29
+ * A 5xx is the server having a bad moment and a 429 is a rate limit that will
30
+ * pass. Everything else — a bad request, a missing scope, a row that is not
31
+ * there — will fail identically the second time, and retrying it turns one
32
+ * clear refusal into three and a longer wait before the model sees it.
33
+ */
34
+ function retryable(status) {
35
+ return status === 429 || status >= 500;
36
+ }
37
+ const ATTEMPTS = 3;
38
+ export class SnareClient {
39
+ config;
40
+ fetchImpl;
41
+ constructor(config, fetchImpl = globalThis.fetch) {
42
+ this.config = config;
43
+ this.fetchImpl = fetchImpl;
44
+ }
45
+ get dashboardUrl() {
46
+ return this.config.dashboardUrl;
47
+ }
48
+ get(path, query) {
49
+ return this.request("GET", withQuery(path, query));
50
+ }
51
+ post(path, body, options) {
52
+ return this.request("POST", path, body, options);
53
+ }
54
+ patch(path, body) {
55
+ return this.request("PATCH", path, body);
56
+ }
57
+ put(path, body) {
58
+ return this.request("PUT", path, body);
59
+ }
60
+ delete(path, body) {
61
+ return this.request("DELETE", path, body);
62
+ }
63
+ async request(method, path, body, options) {
64
+ const url = `${this.config.baseUrl}${path}`;
65
+ const timeoutMs = options?.timeoutMs ?? this.config.requestTimeoutMs;
66
+ // Retrying is right for a stateless read and wrong for a call that is
67
+ // already a long poll: a local run's step holds for up to twenty-five
68
+ // seconds by design, so three attempts against a slow or unreachable
69
+ // orchestrator is a tool call that hangs for two minutes and then reports
70
+ // a failure the first attempt already knew about. Measured, not guessed —
71
+ // a live pass against a stopped orchestrator produced exactly that.
72
+ const attempts = options?.retry === false ? 1 : ATTEMPTS;
73
+ let lastError = null;
74
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
75
+ let response;
76
+ try {
77
+ response = await this.fetchImpl(url, {
78
+ method,
79
+ headers: {
80
+ authorization: `Bearer ${this.config.token}`,
81
+ "content-type": "application/json",
82
+ // Named so a customer reading their own audit log can tell a call
83
+ // from their editor apart from one from a CI script.
84
+ "user-agent": "snare-mcp",
85
+ },
86
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
87
+ signal: AbortSignal.timeout(timeoutMs),
88
+ });
89
+ }
90
+ catch (err) {
91
+ // A network failure or a timeout. Retried like a 5xx, and reported
92
+ // without the underlying message, which is usually a DNS error nobody
93
+ // can act on.
94
+ lastError = new SnareApiError(0, "unreachable", "Could not reach Snare.");
95
+ if (attempt === attempts)
96
+ throw lastError;
97
+ await backoff(attempt);
98
+ continue;
99
+ }
100
+ if (response.ok) {
101
+ if (response.status === 204)
102
+ return undefined;
103
+ return (await response.json());
104
+ }
105
+ const payload = (await response.json().catch(() => null));
106
+ lastError = new SnareApiError(response.status, payload?.code ?? "unknown",
107
+ // The server's own sentence, because the routes are written to be read:
108
+ // a missing scope names the scope, a conflict says what is in the way.
109
+ // Inventing a message here would replace a specific one with a generic.
110
+ payload?.error ?? `Snare answered ${response.status}.`);
111
+ if (!retryable(response.status) || attempt === attempts)
112
+ throw lastError;
113
+ await backoff(attempt);
114
+ }
115
+ throw lastError ?? new SnareApiError(0, "unknown", "Could not reach Snare.");
116
+ }
117
+ }
118
+ function backoff(attempt) {
119
+ // 200ms, then 400ms. Short on purpose: a person is waiting on the other end
120
+ // of this, and a retry schedule measured in seconds turns a transient blip
121
+ // into an agent that appears to have hung.
122
+ return new Promise((resolve) => setTimeout(resolve, 200 * 2 ** (attempt - 1)));
123
+ }
124
+ function withQuery(path, query) {
125
+ if (!query)
126
+ return path;
127
+ const params = new URLSearchParams();
128
+ for (const [key, value] of Object.entries(query)) {
129
+ // Undefined means "not asked for" and must not become the string
130
+ // "undefined", which every one of these routes would reject as a bad value.
131
+ if (value === undefined)
132
+ continue;
133
+ params.set(key, String(value));
134
+ }
135
+ const encoded = params.toString();
136
+ return encoded ? `${path}?${encoded}` : path;
137
+ }
@@ -0,0 +1,84 @@
1
+ import { type Toolset } from "./toolsets.js";
2
+ /**
3
+ * What the server needs to know before it can do anything.
4
+ *
5
+ * ONE OBJECT, PASSED IN, NEVER READ FROM `process.env` DEEPER IN. The stdio
6
+ * binary reads the environment; the remote transport reads a request header.
7
+ * Anything below this file that reached for `process.env` would work in one of
8
+ * those and silently do nothing in the other, and the failure would look like a
9
+ * broken token rather than a missing branch.
10
+ */
11
+ export interface McpConfig {
12
+ /** Where the API lives, without a trailing slash. */
13
+ baseUrl: string;
14
+ /** The `snr_` token. Never logged, never included in an error message. */
15
+ token: string;
16
+ /** Which groups of tools to register. See `toolsets.ts`. */
17
+ toolsets: Toolset[];
18
+ /** How long any one API call may take. */
19
+ requestTimeoutMs: number;
20
+ /** Where a person opens what a tool is describing. */
21
+ dashboardUrl: string;
22
+ }
23
+ export declare const DEFAULT_BASE_URL = "https://snare.dev/api/v1";
24
+ export declare const DEFAULT_DASHBOARD_URL = "https://snare.dev";
25
+ /**
26
+ * How long a call may take.
27
+ *
28
+ * Thirty seconds rather than the default none. Every tool here is one HTTP
29
+ * round trip to a route that does a handful of queries, so a call still running
30
+ * after thirty seconds is a call that is not coming back — and an MCP client
31
+ * with no timeout of its own will sit on it forever, which reads to the person
32
+ * watching as the agent having stopped.
33
+ *
34
+ * The local-run step tool overrides this: it deliberately blocks for up to
35
+ * twenty-five seconds on the server side before answering, so its own ceiling
36
+ * has to be higher than that or the poll would time out exactly when it worked.
37
+ */
38
+ export declare const DEFAULT_TIMEOUT_MS = 30000;
39
+ export declare class ConfigError extends Error {
40
+ }
41
+ /**
42
+ * Builds a config, or explains what is missing.
43
+ *
44
+ * THE MESSAGE MATTERS MORE THAN THE VALIDATION. This is the first thing a new
45
+ * user hits, usually inside a client that shows them one line of stderr, so a
46
+ * missing token says which variable to set and where to get one rather than
47
+ * throwing a stack trace about an undefined property.
48
+ */
49
+ export declare function buildConfig(input: {
50
+ token?: string | undefined;
51
+ baseUrl?: string | undefined;
52
+ dashboardUrl?: string | undefined;
53
+ toolsets?: string | undefined;
54
+ requestTimeoutMs?: number | undefined;
55
+ }): McpConfig;
56
+ /**
57
+ * Which groups to turn on.
58
+ *
59
+ * An unknown name is refused rather than ignored. Ignoring it means somebody
60
+ * who typed `issue` instead of `issues` gets the default set, sees roughly the
61
+ * tools they expected, and never finds out their configuration did nothing.
62
+ */
63
+ export declare function parseToolsets(raw: string | undefined): Toolset[];
64
+ /**
65
+ * What a caller gets without asking: everything.
66
+ *
67
+ * IT USED TO BE FOUR SETS, on the reasoning that every registered tool is
68
+ * schema a model reads on every single turn, so a catalogue nobody is going to
69
+ * call costs context that could have gone on the bug. That reasoning is sound
70
+ * and it was still the wrong default, for a reason only visible once an agent
71
+ * used it: a model cannot tell "Snare does not do this" from "that set is not
72
+ * switched on", and it says the first. Asked what people were requesting, an
73
+ * agent answered that Snare has no feature-request surface at all — a false
74
+ * statement about the product, made confidently, to a customer. Another could
75
+ * not assign an issue because the tool for finding who to assign to was in a
76
+ * set nobody turns on.
77
+ *
78
+ * The cost of the old default was paid by everybody who did not know to change
79
+ * it, and it was paid in wrong answers. The cost of this one is context, on the
80
+ * turns where a set goes unused, and it is one environment variable to reclaim:
81
+ * `SNARE_MCP_TOOLSETS=issues,events` narrows it to whatever a caller actually
82
+ * wants.
83
+ */
84
+ export declare const DEFAULT_TOOLSETS: readonly Toolset[];
@@ -0,0 +1,103 @@
1
+ import { TOOLSETS } from "./toolsets.js";
2
+ export const DEFAULT_BASE_URL = "https://snare.dev/api/v1";
3
+ export const DEFAULT_DASHBOARD_URL = "https://snare.dev";
4
+ /**
5
+ * How long a call may take.
6
+ *
7
+ * Thirty seconds rather than the default none. Every tool here is one HTTP
8
+ * round trip to a route that does a handful of queries, so a call still running
9
+ * after thirty seconds is a call that is not coming back — and an MCP client
10
+ * with no timeout of its own will sit on it forever, which reads to the person
11
+ * watching as the agent having stopped.
12
+ *
13
+ * The local-run step tool overrides this: it deliberately blocks for up to
14
+ * twenty-five seconds on the server side before answering, so its own ceiling
15
+ * has to be higher than that or the poll would time out exactly when it worked.
16
+ */
17
+ export const DEFAULT_TIMEOUT_MS = 30_000;
18
+ export class ConfigError extends Error {
19
+ }
20
+ /**
21
+ * Builds a config, or explains what is missing.
22
+ *
23
+ * THE MESSAGE MATTERS MORE THAN THE VALIDATION. This is the first thing a new
24
+ * user hits, usually inside a client that shows them one line of stderr, so a
25
+ * missing token says which variable to set and where to get one rather than
26
+ * throwing a stack trace about an undefined property.
27
+ */
28
+ export function buildConfig(input) {
29
+ const token = input.token?.trim();
30
+ if (!token) {
31
+ throw new ConfigError("No Snare token. Set SNARE_API_TOKEN to a token from https://snare.dev/settings/api-tokens.");
32
+ }
33
+ if (!token.startsWith("snr_")) {
34
+ // Caught here rather than at the first 401, because "that token is not
35
+ // usable" from the server is deliberately vague and would send somebody
36
+ // looking for a revoked token when they had actually pasted a project key.
37
+ throw new ConfigError('That does not look like a Snare token. They begin with "snr_".');
38
+ }
39
+ const baseUrl = (input.baseUrl?.trim() || DEFAULT_BASE_URL).replace(/\/$/, "");
40
+ const dashboardUrl = (input.dashboardUrl?.trim() || deriveDashboardUrl(baseUrl)).replace(/\/$/, "");
41
+ return {
42
+ token,
43
+ baseUrl,
44
+ dashboardUrl,
45
+ toolsets: parseToolsets(input.toolsets),
46
+ requestTimeoutMs: input.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
47
+ };
48
+ }
49
+ /**
50
+ * The dashboard is the API's base with the API path taken off.
51
+ *
52
+ * Derived rather than required, so somebody pointing this at a staging
53
+ * deployment sets one variable instead of two and cannot set them
54
+ * inconsistently. `SNARE_DASHBOARD_URL` overrides it for the case where they
55
+ * really are on different hosts.
56
+ */
57
+ function deriveDashboardUrl(baseUrl) {
58
+ return baseUrl.replace(/\/api\/v1$/, "") || DEFAULT_DASHBOARD_URL;
59
+ }
60
+ /**
61
+ * Which groups to turn on.
62
+ *
63
+ * An unknown name is refused rather than ignored. Ignoring it means somebody
64
+ * who typed `issue` instead of `issues` gets the default set, sees roughly the
65
+ * tools they expected, and never finds out their configuration did nothing.
66
+ */
67
+ export function parseToolsets(raw) {
68
+ const value = raw?.trim();
69
+ if (!value)
70
+ return [...DEFAULT_TOOLSETS];
71
+ if (value === "all")
72
+ return [...TOOLSETS];
73
+ const names = value
74
+ .split(",")
75
+ .map((name) => name.trim())
76
+ .filter(Boolean);
77
+ const unknown = names.filter((name) => !TOOLSETS.includes(name));
78
+ if (unknown.length > 0) {
79
+ throw new ConfigError(`Unknown tool set: ${unknown.join(", ")}. Pick from ${TOOLSETS.join(", ")}, or "all".`);
80
+ }
81
+ return names;
82
+ }
83
+ /**
84
+ * What a caller gets without asking: everything.
85
+ *
86
+ * IT USED TO BE FOUR SETS, on the reasoning that every registered tool is
87
+ * schema a model reads on every single turn, so a catalogue nobody is going to
88
+ * call costs context that could have gone on the bug. That reasoning is sound
89
+ * and it was still the wrong default, for a reason only visible once an agent
90
+ * used it: a model cannot tell "Snare does not do this" from "that set is not
91
+ * switched on", and it says the first. Asked what people were requesting, an
92
+ * agent answered that Snare has no feature-request surface at all — a false
93
+ * statement about the product, made confidently, to a customer. Another could
94
+ * not assign an issue because the tool for finding who to assign to was in a
95
+ * set nobody turns on.
96
+ *
97
+ * The cost of the old default was paid by everybody who did not know to change
98
+ * it, and it was paid in wrong answers. The cost of this one is context, on the
99
+ * turns where a set goes unused, and it is one environment variable to reclaim:
100
+ * `SNARE_MCP_TOOLSETS=issues,events` narrows it to whatever a caller actually
101
+ * wants.
102
+ */
103
+ export const DEFAULT_TOOLSETS = [...TOOLSETS];
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Turning a record into the text a model reads.
3
+ *
4
+ * THIS FILE IS WHY THE TOOLS ARE USABLE. The difference between a tool an agent
5
+ * uses well and one it uses badly is mostly the shape of what comes back. Raw
6
+ * JSON rows make a model rebuild the domain on every call: it has to work out
7
+ * that `IN_PROGRESS` is a status, that `null` in one field means nobody and in
8
+ * another means zero, and that 11747 is seconds. Every one of those is a chance
9
+ * to get it wrong in a sentence somebody then reads.
10
+ *
11
+ * So every response is text, led by one line saying what it is, with the
12
+ * structured facts underneath in a fixed shape. The rules below are the same
13
+ * ones the product's own UI follows, for the same reasons — they are in
14
+ * CLAUDE.md and they are not arbitrary.
15
+ */
16
+ /**
17
+ * No value, and visibly not zero.
18
+ *
19
+ * An em dash rather than "none", "null" or an empty string: a reader (human or
20
+ * model) has to be able to tell "nobody is assigned" from "zero people are
21
+ * assigned", and every text form of absence collides with some real value
22
+ * somewhere.
23
+ */
24
+ export declare const DASH = "\u2014";
25
+ /** Sentence case for an enum. `IN_PROGRESS` is a column value, "In progress" is language. */
26
+ export declare function label(value: string | null | undefined): string;
27
+ /**
28
+ * A duration in human units, never a clock and never raw seconds.
29
+ *
30
+ * Two units at most: "3 hrs 15 min", not "3 hrs 15 min 47 s". The third unit is
31
+ * always noise at the scale the second one is already showing, and a reader
32
+ * skimming a list stops at the first two anyway.
33
+ */
34
+ export declare function duration(seconds: number | null | undefined): string;
35
+ /**
36
+ * How far away a moment is, in either direction.
37
+ *
38
+ * The ISO timestamp is always alongside it: this is the part somebody reads,
39
+ * that is the part they compute with.
40
+ *
41
+ * THE FUTURE IS A REAL CASE HERE and used to be clamped to zero, which rendered
42
+ * every one of them as "just now". Not everything this formats has happened:
43
+ * a billing period ends, a trial expires, a question's window closes. "This
44
+ * period ends 2026-10-07 (just now)" is a sentence that contradicts itself, and
45
+ * the reader it misleads is the one deciding whether they can afford to start
46
+ * a run.
47
+ */
48
+ export declare function ago(iso: string | null | undefined, now?: Date): string;
49
+ /**
50
+ * A moment, to the minute, without the machine punctuation.
51
+ *
52
+ * `2026-09-06 08:52` rather than `2026-09-06T08:52:58.296Z`. The `T`, the
53
+ * seconds and the milliseconds are three pieces of precision no answer about an
54
+ * issue has ever needed, and in a column of forty rows they are most of the
55
+ * width. Always UTC, because a server-rendered local time is a time in a
56
+ * timezone the reader cannot see.
57
+ */
58
+ export declare function stamp(iso: string | null | undefined): string;
59
+ /**
60
+ * A moment AND how long ago it was: `2026-09-06 08:52 UTC (1 d ago)`.
61
+ *
62
+ * For detail cards, where there is room for both and both get used. "Last seen"
63
+ * answers two different questions — is this still happening, and exactly when
64
+ * did it last happen — and a bare timestamp only answers the second, leaving
65
+ * the model to do date arithmetic in prose. It gets that wrong often enough
66
+ * that giving it the answer is worth the twelve characters.
67
+ *
68
+ * `ago()` alone is not enough here for the opposite reason: "3 d ago" cannot be
69
+ * quoted into a report, correlated with a deploy, or compared with anything.
70
+ */
71
+ export declare function when(iso: string | null | undefined, now?: Date): string;
72
+ /**
73
+ * A quantity that agrees with its noun: "1 occurrence", "4 occurrences".
74
+ *
75
+ * `count()` above covers "12 of 340 issues". This covers the plain case, which
76
+ * was being written inline as `${n} issues` in six places and produced
77
+ * "1 occurrences", "1 issues" and "1 sessions" in real output. A model reading
78
+ * that is fine; a person reading the sentence the model then writes is reading
79
+ * a mistake the product made, and the fix is one function rather than six
80
+ * ternaries nobody will remember to add the seventh time.
81
+ */
82
+ export declare function quantity(amount: number, noun: string, plural?: string): string;
83
+ /**
84
+ * A count that says what it is out of.
85
+ *
86
+ * "12 of 340 issues", never "12". A list that does not say what it is a slice of
87
+ * is a list whose answer changes meaning depending on something the reader
88
+ * cannot see, and an agent reporting "there are 12 open issues" from a page of
89
+ * 12 has said something false.
90
+ */
91
+ export declare function count(shown: number, total: number, noun: string, plural?: string): string;
92
+ /** `Label: value`, with absence rendered as the dash rather than omitted. */
93
+ export declare function line(name: string, value: string | number | null | undefined): string;
94
+ /** A block of `Label: value` lines, blanks dropped, in the order given. */
95
+ export declare function facts(entries: Array<[string, string | number | null | undefined]>): string;
96
+ /**
97
+ * A tool's whole answer: one summary line, a blank, then the body.
98
+ *
99
+ * The summary exists so a model that reads nothing else still knows what it got.
100
+ * It is also what a client renders in a collapsed tool-call row, which is the
101
+ * only thing many readers will ever see of this.
102
+ */
103
+ export declare function toolText(summary: string, ...blocks: Array<string | null | undefined>): string;
104
+ /** A bulleted list, or a sentence saying there is nothing. */
105
+ export declare function bullets(items: readonly string[], emptyMessage: string): string;
106
+ /**
107
+ * Truncates a long free-text field for a list row.
108
+ *
109
+ * A stack trace or a comment thread pasted whole into a fifty-row listing is
110
+ * the single easiest way to fill a context window with something nobody asked
111
+ * for. Detail calls return the whole thing; lists return this.
112
+ */
113
+ export declare function clip(text: string | null | undefined, max?: number): string;
114
+ /** A percentage from a 0-1 score, or the dash. Confidence and similarity both use this. */
115
+ export declare function percent(value: number | null | undefined): string;
116
+ /**
117
+ * A whole number with thousands separators.
118
+ *
119
+ * `50000` and `49953` next to each other in one sentence are two five-digit
120
+ * runs a reader has to count digits to tell apart, and a model asked to
121
+ * summarise "47 of 50000 used, 49953 left" reported it back as "49,953 of 50k
122
+ * used" — the opposite of the truth, on a line about somebody's bill.
123
+ */
124
+ export declare function num(value: number | null | undefined): string;
125
+ /** Money, always with the unit, because a bare number here reads as anything. */
126
+ export declare function usd(value: number | null | undefined): string;