@crvouga/atlas 1.12.0 → 1.13.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +7 -1
  2. package/dist/critics/src/contract.d.ts +113 -0
  3. package/dist/critics/src/contract.js +48 -0
  4. package/dist/critics/src/credentials.d.ts +72 -0
  5. package/dist/critics/src/credentials.js +136 -0
  6. package/dist/critics/src/critique.d.ts +128 -0
  7. package/dist/critics/src/critique.js +163 -0
  8. package/dist/critics/src/index.d.ts +21 -0
  9. package/dist/critics/src/index.js +21 -0
  10. package/dist/critics/src/judging.d.ts +153 -0
  11. package/dist/critics/src/judging.js +145 -0
  12. package/dist/critics/src/publish.d.ts +23 -0
  13. package/dist/critics/src/publish.js +21 -0
  14. package/dist/critics/src/runner.d.ts +104 -0
  15. package/dist/critics/src/runner.js +202 -0
  16. package/dist/critics/src/scope.d.ts +8 -0
  17. package/dist/critics/src/scope.js +26 -0
  18. package/dist/reports/src/compose.d.ts +2 -2
  19. package/dist/reports/src/compose.js +6 -5
  20. package/dist/reports/src/evidence-view.d.ts +2 -1
  21. package/dist/reports/src/evidence-view.js +5 -2
  22. package/dist/reports/src/layer-merge.d.ts +11 -3
  23. package/dist/reports/src/layer-merge.js +14 -3
  24. package/dist/reports/src/report-status.d.ts +1 -1
  25. package/dist/reports/src/report-status.js +6 -4
  26. package/dist/schema/src/api.d.ts +1 -0
  27. package/dist/schema/src/api.js +1 -1
  28. package/dist/schema/src/critique.d.ts +144 -0
  29. package/dist/schema/src/critique.js +96 -0
  30. package/dist/schema/src/index.d.ts +2 -1
  31. package/dist/schema/src/index.js +2 -1
  32. package/dist/schema/src/server-config.d.ts +7 -1
  33. package/dist/schema/src/server-config.js +6 -3
  34. package/dist/server/src/auth.d.ts +1 -1
  35. package/dist/server/src/auth.js +2 -2
  36. package/dist/server/src/config.js +2 -2
  37. package/dist/server/src/openapi.d.ts +3 -3
  38. package/dist/server/src/openapi.js +7 -4
  39. package/dist/server/src/store-api.js +39 -1
  40. package/docs/agents.md +1 -0
  41. package/docs/critics.md +190 -0
  42. package/docs/extending.md +1 -0
  43. package/docs/reference/critics.md +840 -0
  44. package/docs/reference/http-api.md +6 -2
  45. package/docs/reference/reports.md +20 -7
  46. package/docs/reference/schema.md +119 -2
  47. package/docs/reference/server.md +3 -3
  48. package/docs/report-contract.md +4 -0
  49. package/docs/server.md +6 -4
  50. package/llms-full.txt +211 -6
  51. package/llms.txt +2 -0
  52. package/package.json +7 -2
  53. package/packages/critics/README.md +18 -0
  54. package/packages/schema/schemas/critique.schema.json +254 -0
  55. package/packages/schema/schemas/server-config.schema.json +2 -1
  56. package/packages/schema/schemas/server-info.schema.json +1 -0
  57. package/packages/server/openapi.json +7 -6
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @module The `atlas.critique` evidence schema
3
+ *
4
+ * What an automated reviewer (a critic) says about one captured UI. It is not a new layer format: a critique is a
5
+ * check inside a part of kind `critique`, and the opinion rides in the check's `extensions["atlas.critique"]`, so by the
6
+ * rules of docs/extending.md it can never decide a result. The check's `status` says only whether the critic ran
7
+ * (`passed` a verdict was produced, `failed` the critic could not produce one, `running`, `not-run`).
8
+ *
9
+ * Everything except `schema`, `subject` and `critic` is optional, so a single score (a model-based critic) and rich
10
+ * findings (an LLM critic) are one shape. Every object is open, and `kind`, `category`, `severity`, `label` and `reason`
11
+ * are open strings: a value nobody has heard of is kept and shown generically, never rejected. A reader that cannot read
12
+ * the whole extension drops it and keeps the check and its status (`readCritique` in `@crvouga/atlas/critics`).
13
+ *
14
+ * @see docs/critics.md
15
+ * @see docs/extending.md
16
+ */
17
+ import { z } from 'zod/v4';
18
+ /** The key under which a critique is stored in a check's `extensions`. */
19
+ export const CRITIQUE_EXTENSION = 'atlas.critique';
20
+ /** The version of the `atlas.critique` extension a reader of this release understands. A critique of another version is unknown, not wrong. */
21
+ export const CRITIQUE_SCHEMA_VERSION = 1;
22
+ /** The `kind` of the report part that holds critiques. Parts of this kind are opinions: never counted, never part of the gate. */
23
+ export const CRITIQUE_PART_KIND = 'critique';
24
+ /** The prefix of the `producer` of a layer of critiques: `critic.<critic name>`. */
25
+ export const CRITIC_PRODUCER_PREFIX = 'critic.';
26
+ /** The region of the image a finding is about, in the stated unit (`px` of the stored image, or `normalized` 0..1). */
27
+ export const CritiqueRegionSchema = z.looseObject({
28
+ unit: z.string().min(1).describe('`px` (pixels of the stored image) or `normalized` (0..1 of its width and height). Open vocabulary'),
29
+ x: z.number(),
30
+ y: z.number(),
31
+ w: z.number().nonnegative(),
32
+ h: z.number().nonnegative()
33
+ });
34
+ /** One thing a critic found: where, what kind, how bad, why, and what to do. Nothing is required. */
35
+ export const CritiqueFindingSchema = z.looseObject({
36
+ id: z.string().min(1).optional().describe('Stable within the critique, so a reviewer can mark this finding valid or not'),
37
+ category: z.string().min(1).optional().describe('Open vocabulary: `spacing`, `contrast`, `alignment`, `overflow`, `hierarchy`…'),
38
+ severity: z.string().min(1).optional().describe('Open vocabulary, conventionally `info`, `minor`, `major`'),
39
+ confidence: z.number().min(0).max(1).optional(),
40
+ region: CritiqueRegionSchema.optional(),
41
+ rationale: z.string().optional(),
42
+ suggestion: z.string().optional()
43
+ });
44
+ /**
45
+ * How a score reads in context. A raw score is not absolute: a loading overlay is compared with other loading overlays,
46
+ * not with full screens, and a change against the same entry's previous image is more telling than the level.
47
+ */
48
+ export const CritiqueRelativeSchema = z.looseObject({
49
+ stateKind: z.string().min(1).describe('The kind of UI state the score is relative to: `screen`, `overlay`, `loading`, `error`, `empty`…'),
50
+ percentile: z.number().min(0).max(100).optional().describe('Where the score falls among the project\'s scores of the same state kind, 0..100, higher is better'),
51
+ sampleSize: z.number().int().nonnegative().optional().describe('How many scores of that state kind the percentile is over'),
52
+ previousScore: z.number().optional().describe('The same entry\'s score for its previous image, from the same critic'),
53
+ delta: z.number().optional().describe('`score` minus `previousScore`')
54
+ });
55
+ /** The judgement: a score and how to read it, a label, a confidence. A critic that cannot score may give only a label. */
56
+ export const CritiqueVerdictSchema = z.looseObject({
57
+ score: z.number().optional().describe('A finite number. `scale` says what it means'),
58
+ scale: z.string().optional().describe('For example `0..1, higher is better`'),
59
+ label: z.string().optional().describe('Open vocabulary, conventionally `ok` or `needs-polish`'),
60
+ confidence: z.number().min(0).max(1).optional(),
61
+ relative: CritiqueRelativeSchema.optional()
62
+ });
63
+ /** Why a critic produced no verdict: a reason code in an open vocabulary (`error`, `timeout`, `rate-limited`, `invalid-output`) and a message that holds no credential. */
64
+ export const CritiqueFailureSchema = z.looseObject({
65
+ reason: z.string().min(1),
66
+ message: z.string().optional()
67
+ });
68
+ /**
69
+ * The `atlas.critique` extension, version 1: the image judged (`subject`), who judged (`critic`, `model`,
70
+ * `parameters`), the opinion (`verdict`, `findings`) or why there is none (`failure`), and what it cost (`usage`).
71
+ * The prompt is recorded only as a hash (`parameters.rubric`). Emitted as `critique.schema.json`.
72
+ */
73
+ export const CritiqueSchema = z.looseObject({
74
+ schema: z.number().int().positive().describe('The version of this extension. 1'),
75
+ subject: z.looseObject({
76
+ entryKey: z.string().min(1).describe('The catalog entry that was judged'),
77
+ imageSha256: z.string().min(1).describe('The hash of the exact image bytes that were judged. When the entry\'s image changes, this critique judged an earlier image'),
78
+ state: z.string().min(1).optional().describe('The kind of UI state: `screen`, `overlay`, `loading`, `error`, `empty`…')
79
+ }),
80
+ critic: z.looseObject({
81
+ name: z.string().min(1),
82
+ version: z.string().min(1).describe('Of the critic\'s own logic. A newer version replaces an older one'),
83
+ kind: z.string().min(1).optional().describe('Open vocabulary: `model`, `llm`, `ml`, `heuristic`…')
84
+ }),
85
+ model: z.looseObject({ id: z.string().min(1), provider: z.string().optional() }).optional(),
86
+ parameters: z.record(z.string(), z.unknown()).optional().describe('Everything else that explains the result: temperature, seed, the SHA-256 of the rubric'),
87
+ verdict: CritiqueVerdictSchema.optional(),
88
+ findings: z.array(CritiqueFindingSchema).optional(),
89
+ failure: CritiqueFailureSchema.optional(),
90
+ usage: z.looseObject({
91
+ inputTokens: z.number().nonnegative().optional(),
92
+ outputTokens: z.number().nonnegative().optional(),
93
+ latencyMs: z.number().nonnegative().optional(),
94
+ costUsd: z.number().nonnegative().optional()
95
+ }).optional()
96
+ });
@@ -2,7 +2,7 @@
2
2
  * @module Schema package entry point (`@crvouga/atlas/schema`)
3
3
  *
4
4
  * Re-exports every Zod schema, constant and inferred type of the Atlas contract: the layer format and report status
5
- * (`layer`), the statechart run manifest (`manifest`, `common`), the spec file formats (`spec`), the run index and
5
+ * (`layer`), the `atlas.critique` evidence of automated UI reviewers (`critique`), the statechart run manifest (`manifest`, `common`), the spec file formats (`spec`), the run index and
6
6
  * report sources the viewer reads (`runs-index`, `report-sources`) and the server configuration (`server-config`).
7
7
  * The JSON Schemas shipped in `schemas/` are generated from these by `bun run schemas`.
8
8
  *
@@ -14,6 +14,7 @@ export * from './runs-index.js';
14
14
  export * from './spec.js';
15
15
  export * from './report-sources.js';
16
16
  export * from './layer.js';
17
+ export * from './critique.js';
17
18
  export * from './server-config.js';
18
19
  export * from './user-state.js';
19
20
  export * from './api.js';
@@ -2,7 +2,7 @@
2
2
  * @module Schema package entry point (`@crvouga/atlas/schema`)
3
3
  *
4
4
  * Re-exports every Zod schema, constant and inferred type of the Atlas contract: the layer format and report status
5
- * (`layer`), the statechart run manifest (`manifest`, `common`), the spec file formats (`spec`), the run index and
5
+ * (`layer`), the `atlas.critique` evidence of automated UI reviewers (`critique`), the statechart run manifest (`manifest`, `common`), the spec file formats (`spec`), the run index and
6
6
  * report sources the viewer reads (`runs-index`, `report-sources`) and the server configuration (`server-config`).
7
7
  * The JSON Schemas shipped in `schemas/` are generated from these by `bun run schemas`.
8
8
  *
@@ -14,6 +14,7 @@ export * from './runs-index.js';
14
14
  export * from './spec.js';
15
15
  export * from './report-sources.js';
16
16
  export * from './layer.js';
17
+ export * from './critique.js';
17
18
  export * from './server-config.js';
18
19
  export * from './user-state.js';
19
20
  export * from './api.js';
@@ -11,9 +11,13 @@
11
11
  * @see docs/server.md
12
12
  */
13
13
  import { z } from 'zod/v4';
14
- /** What a bearer token may do: `read` (look), `publish` (also upload) or `admin` (also remove scratch objects). */
14
+ /**
15
+ * What a bearer token may do: `read` (look), `critic` (also publish critiques, and nothing else), `publish` (also upload
16
+ * anything) or `admin` (also remove scratch objects).
17
+ */
15
18
  export declare const ServerRoleSchema: z.ZodEnum<{
16
19
  read: "read";
20
+ critic: "critic";
17
21
  publish: "publish";
18
22
  admin: "admin";
19
23
  }>;
@@ -27,6 +31,7 @@ export declare const ServerTokenSchema: z.ZodObject<{
27
31
  name: z.ZodString;
28
32
  role: z.ZodEnum<{
29
33
  read: "read";
34
+ critic: "critic";
30
35
  publish: "publish";
31
36
  admin: "admin";
32
37
  }>;
@@ -45,6 +50,7 @@ export declare const ServerConfigSchema: z.ZodObject<{
45
50
  name: z.ZodString;
46
51
  role: z.ZodEnum<{
47
52
  read: "read";
53
+ critic: "critic";
48
54
  publish: "publish";
49
55
  admin: "admin";
50
56
  }>;
@@ -11,8 +11,11 @@
11
11
  * @see docs/server.md
12
12
  */
13
13
  import { z } from 'zod/v4';
14
- /** What a bearer token may do: `read` (look), `publish` (also upload) or `admin` (also remove scratch objects). */
15
- export const ServerRoleSchema = z.enum(['read', 'publish', 'admin']);
14
+ /**
15
+ * What a bearer token may do: `read` (look), `critic` (also publish critiques, and nothing else), `publish` (also upload
16
+ * anything) or `admin` (also remove scratch objects).
17
+ */
18
+ export const ServerRoleSchema = z.enum(['read', 'critic', 'publish', 'admin']);
16
19
  /**
17
20
  * A bearer token: a name for logs, a role, and the token itself or the name of the environment variable that holds it
18
21
  * (exactly one of the two).
@@ -20,7 +23,7 @@ export const ServerRoleSchema = z.enum(['read', 'publish', 'admin']);
20
23
  export const ServerTokenSchema = z
21
24
  .strictObject({
22
25
  name: z.string().min(1).describe('Shown in logs, never the token itself'),
23
- role: ServerRoleSchema.describe('`read` may look. `publish` may also upload. `admin` may also remove scratch objects'),
26
+ role: ServerRoleSchema.describe('`read` may look. `critic` may also publish critiques (layers of producer `critic.*` whose parts are all of kind `critique`). `publish` may also upload. `admin` may also remove scratch objects'),
24
27
  token: z.string().min(12).optional().describe('The token. Prefer `tokenEnv`, so the file holds no secret'),
25
28
  tokenEnv: z.string().optional().describe('The name of the environment variable that holds the token')
26
29
  })
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @module Authentication and roles
3
3
  *
4
- * Who is asking, and may they. Tokens have a role (`read` < `publish` < `admin`) and are compared in constant time. Reading
4
+ * Who is asking, and may they. Tokens have a role (`read` < `critic` < `publish` < `admin`) and are compared in constant time. Reading
5
5
  * accepts a Bearer token or the browser's sign-in box (any user name, the token as the password); changing anything needs
6
6
  * the Bearer header, because a browser sends the sign-in box's credentials to any page that asks, which would let another
7
7
  * site upload with them. A server with no tokens is open, and refuses to listen beyond this machine unless told it may.
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * @module Authentication and roles
3
3
  *
4
- * Who is asking, and may they. Tokens have a role (`read` < `publish` < `admin`) and are compared in constant time. Reading
4
+ * Who is asking, and may they. Tokens have a role (`read` < `critic` < `publish` < `admin`) and are compared in constant time. Reading
5
5
  * accepts a Bearer token or the browser's sign-in box (any user name, the token as the password); changing anything needs
6
6
  * the Bearer header, because a browser sends the sign-in box's credentials to any page that asks, which would let another
7
7
  * site upload with them. A server with no tokens is open, and refuses to listen beyond this machine unless told it may.
8
8
  */
9
9
  import { timingSafeEqual } from 'node:crypto';
10
- const RANK = { read: 1, publish: 2, admin: 3 };
10
+ const RANK = { read: 1, critic: 2, publish: 3, admin: 4 };
11
11
  function same(a, b) {
12
12
  const left = Buffer.from(a), right = Buffer.from(b);
13
13
  return left.length === right.length && timingSafeEqual(left, right);
@@ -60,7 +60,7 @@ export const SERVER_SETTINGS = [
60
60
  { summary: 'How often (seconds) the backends are looked at for layers written by something else', key: 'refreshSeconds', flag: '--refresh <seconds>', env: ['ATLAS_REFRESH_SECONDS'], default: '`10`', example: { value: '30', read: c => c.refreshSeconds, expected: 30 } },
61
61
  { summary: 'Let anyone look, not upload', key: 'auth.anonymousRead', flag: '--anonymous-read', env: ['ATLAS_ANONYMOUS_READ'], default: 'no', example: { value: '1', read: c => c.anonymousRead, expected: true } },
62
62
  { summary: 'Listen publicly with no tokens (something in front signs people in)', key: 'auth.insecure', flag: '--insecure', env: ['ATLAS_INSECURE'], default: 'no', example: { value: '1', read: c => c.insecure, expected: true } },
63
- { summary: 'The admin token (also `ATLAS_PUBLISH_TOKEN` and `ATLAS_READ_TOKEN` for the other roles, or `auth.tokens` in the file)', key: 'auth.tokens', flag: null, env: ['ATLAS_TOKEN'], default: 'none: the server is open and listens on this machine only', example: { value: 'admin-token-123456', read: c => c.tokens.map(token => token.role), expected: ['admin'] } },
63
+ { summary: 'The admin token (also `ATLAS_PUBLISH_TOKEN`, `ATLAS_CRITIC_TOKEN` and `ATLAS_READ_TOKEN` for the other roles, or `auth.tokens` in the file)', key: 'auth.tokens', flag: null, env: ['ATLAS_TOKEN'], default: 'none: the server is open and listens on this machine only', example: { value: 'admin-token-123456', read: c => c.tokens.map(token => token.role), expected: ['admin'] } },
64
64
  { summary: 'The configuration file', key: '(the file itself)', flag: '--config <file>', env: ['ATLAS_CONFIG'], default: '`./atlas.server.json` when it exists', example: { value: 'custom.json', read: c => path.basename(c.configFile ?? ''), expected: 'custom.json' } },
65
65
  { summary: 'Largest uploaded object in MiB', key: 'limits.maxObjectMiB', flag: null, env: ['ATLAS_MAX_OBJECT_MIB'], default: '`256`', example: { value: '64', read: c => c.limits.maxObjectMiB, expected: 64 } },
66
66
  { summary: 'Largest JSON upload in MiB', key: 'limits.maxJsonMiB', flag: null, env: ['ATLAS_MAX_JSON_MIB'], default: '`32`', example: { value: '8', read: c => c.limits.maxJsonMiB, expected: 8 } },
@@ -172,7 +172,7 @@ export function loadServerConfig(argv, env, cwd, read = file => readFileSync(fil
172
172
  throw new ConfigError(`Token "${item.name}" reads ${item.tokenEnv} and it is not set`);
173
173
  tokens.push({ name: item.name, role: item.role, token });
174
174
  }
175
- for (const [variable, role, name] of [['ATLAS_TOKEN', 'admin', 'admin'], ['ATLAS_PUBLISH_TOKEN', 'publish', 'publish'], ['ATLAS_READ_TOKEN', 'read', 'read']]) {
175
+ for (const [variable, role, name] of [['ATLAS_TOKEN', 'admin', 'admin'], ['ATLAS_PUBLISH_TOKEN', 'publish', 'publish'], ['ATLAS_CRITIC_TOKEN', 'critic', 'critic'], ['ATLAS_READ_TOKEN', 'read', 'read']]) {
176
176
  if (env[variable])
177
177
  tokens.push({ name, role, token: env[variable].trim() });
178
178
  }
@@ -1,7 +1,7 @@
1
1
  /** The version of the API described, `major.minor.patch`: the minor grows when an operation or field is added, the major never within `/api`. */
2
- export declare const API_VERSION = "1.5.0";
3
- /** Who may call an operation: `none` (anyone), or the least role that may (`read` < `publish` < `admin`). */
4
- export type Role = 'none' | 'read' | 'publish' | 'admin';
2
+ export declare const API_VERSION = "1.6.0";
3
+ /** Who may call an operation: `none` (anyone), or the least role that may (`read` < `critic` < `publish` < `admin`). */
4
+ export type Role = 'none' | 'read' | 'critic' | 'publish' | 'admin';
5
5
  type JsonSchema = Record<string, unknown>;
6
6
  type Param = {
7
7
  readonly name: string;
@@ -17,7 +17,7 @@ import { ProjectsSchema } from '../../schema/src/project.js';
17
17
  import { EffectiveDeltaSchema, EffectiveEvidenceSchema, EvidencePageSchema, HealthSchema, LayerRowsSchema, CheckDetailSchema, OutlineSchema, ProblemSchema, ReadySchema, ServerInfoSchema, StoreListingSchema, StoreWriteResultSchema, ChecksResultSchema, HelloEventSchema, ReportEventSchema, PingEventSchema, TestCatalogSchema, CatalogHealthSchema, TestHistorySchema, TestReliabilitySchema, RunListSchema, RunDetailSchema, ArtifactListSchema, ArtifactTagEditSchema, ArtifactTagEntrySchema, ArtifactTagsSchema, ReviewCollectionRequestSchema, ReviewCollectionSchema, ReviewCollectionListSchema, ReviewCollectionDetailSchema, ReviewNoteInputSchema, ReviewNoteSchema, ReviewManifestSchema } from '../../schema/src/api.js';
18
18
  import { LiveRunSchema, LiveRunStartSchema, LiveRunUpdateSchema, ServerEditableSettingsSchema, ServerSettingsSnapshotSchema, GcPolicySchema, GcSnapshotSchema, GcRunResultSchema, AtlasChangeSchema, PublishChecksSchema, ReportSourcesSchema, ReportStatusSchema, RunsIndexSchema, SpecIndexSchema } from '../../schema/src/index.js';
19
19
  /** The version of the API described, `major.minor.patch`: the minor grows when an operation or field is added, the major never within `/api`. */
20
- export const API_VERSION = '1.5.0';
20
+ export const API_VERSION = '1.6.0';
21
21
  /** The JSON Schema of a Zod schema, as OpenAPI 3.1 embeds it. `output` for what the server sends, `input` for what it accepts. */
22
22
  function schemaOf(schema, io = 'output') {
23
23
  const { $schema: _dialect, ...json } = z.toJSONSchema(schema, { target: 'draft-2020-12', unrepresentable: 'any', io });
@@ -246,7 +246,7 @@ const BASE_OPERATIONS = [
246
246
  responses: { '200': { description: 'It exists.' }, '401': UNAUTHORIZED, '404': problem('No such object.') }
247
247
  },
248
248
  {
249
- route: 'PUT /api/store/*', path: '/api/store/{key}', operationId: 'putObject', tag: 'Store protocol', role: 'publish',
249
+ route: 'PUT /api/store/*', path: '/api/store/{key}', operationId: 'putObject', tag: 'Store protocol', role: 'critic',
250
250
  summary: 'Upload an object',
251
251
  description: [
252
252
  'The write half of the `ObjectStore` interface. The server checks what it can from the bytes in hand and says no to what would corrupt the report for everyone:',
@@ -257,7 +257,9 @@ const BASE_OPERATIONS = [
257
257
  '- a **run manifest** must have passed its privacy scan;',
258
258
  '- keys are immutable: the same bytes again are `200` (a retry), other bytes under the same key are `409`.',
259
259
  '',
260
- 'Upload in dependency order: blobs, then the manifest, then the catalog entry. `atlas report publish`, `atlas report stream` and `httpStore` do. Needs a `Bearer` token of role `publish` or `admin`; a browser\'s sign-in box is refused.'
260
+ 'Upload in dependency order: blobs, then the manifest, then the catalog entry. `atlas report publish`, `atlas report stream` and `httpStore` do. Needs a `Bearer` token of role `publish` or `admin`; a browser\'s sign-in box is refused.',
261
+ '',
262
+ 'A token of role `critic` may upload only what a critic publishes: blobs, and layers (and their catalog entries) whose `producer` is `critic.<name>` and whose parts are all of kind `critique`. Anything else is `403` with the rule it broke, and nothing is stored.'
261
263
  ].join('\n'),
262
264
  parameters: [KEY, { name: 'content-type', in: 'header', description: 'Stored with the object and returned when it is read.', schema: str }],
263
265
  body: { description: 'The object\'s bytes.', contentType: 'application/octet-stream', schema: { type: 'string', format: 'binary' }, required: true },
@@ -590,6 +592,7 @@ export const NOT_OPERATIONS = new Map([
590
592
  const SECURITY = {
591
593
  none: [],
592
594
  read: [{ bearer: [] }, { basic: [] }],
595
+ critic: [{ bearer: [] }],
593
596
  publish: [{ bearer: [] }],
594
597
  admin: [{ bearer: [] }]
595
598
  };
@@ -623,7 +626,7 @@ export function openApiDocument(options = {}) {
623
626
  description: [
624
627
  'One Atlas node serves multiple projects. Clients select a project using ?project=<slug>, X-Atlas-Project, or /api/projects/<slug>/… . Each project has independent specs, layers, recordings, status, and event streams. Within each project, the newest completed result of every check wins its slot. Unqualified requests address the legacy default project.',
625
628
  '',
626
- '**Authentication.** With tokens configured, send `Authorization: Bearer <token>`. Roles: `read` < `publish` < `admin`. Uploading needs a Bearer token; a browser\'s HTTP Basic sign-in (any user name, the token as the password) is accepted for reading only. A server with no tokens is open and listens on this machine only unless told otherwise.',
629
+ '**Authentication.** With tokens configured, send `Authorization: Bearer <token>`. Roles: `read` < `critic` < `publish` < `admin`. A `critic` token may read, and publish critiques (layers of automated reviewers) and nothing else. Uploading needs a Bearer token; a browser\'s HTTP Basic sign-in (any user name, the token as the password) is accepted for reading only. A server with no tokens is open and listens on this machine only unless told otherwise.',
627
630
  '',
628
631
  '**Errors** are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem documents (`application/problem+json`) with a `detail` that says what to do. `401` send a token, `403` that token may not, `409` upload what this depends on first or you are changing something immutable, `413` too large, `422` not valid, `503` still starting (retry after `Retry-After`).',
629
632
  '',
@@ -12,6 +12,7 @@ import { Hono } from 'hono';
12
12
  import { isPaged } from '../../reports/src/object-store.js';
13
13
  import { readBytes } from '../../reports/src/object-io.js';
14
14
  import { UNTRUSTED_HEADERS } from '../../reports/src/attachment-response.js';
15
+ import { criticScopeViolation } from '../../critics/src/scope.js';
15
16
  import { classify, isCatalog, Refusal, sameBytes, validateUpload, validKey } from './validate.js';
16
17
  /** Read a request body, refusing as soon as it is over the limit rather than after holding it all. */
17
18
  export async function readLimited(request, limit) {
@@ -55,6 +56,40 @@ const decode = (c, prefix) => {
55
56
  return null;
56
57
  }
57
58
  };
59
+ const decoder = new TextDecoder();
60
+ /**
61
+ * What a critic-role token may upload: blobs, and layers (and the catalog entries that make them visible) whose producer
62
+ * is `critic.<name>` and whose parts are all of kind `critique`. Anything else, including a key that is not a layer at
63
+ * all, is refused with `403` and the rule it broke, before anything is stored. Blobs are checked against their name by
64
+ * the upload validation, so on their own they are harmless.
65
+ */
66
+ async function assertCriticMayStore(name, bytes, store) {
67
+ const known = classify(name);
68
+ if (known?.kind === 'blob')
69
+ return;
70
+ if (known?.kind !== 'layer' && known?.kind !== 'layer-catalog')
71
+ throw new Refusal(403, `A critic token may upload only critiques, not "${name}". Use a publish token for anything else.`);
72
+ let text;
73
+ if (known.kind === 'layer')
74
+ text = decoder.decode(bytes);
75
+ else {
76
+ const stored = await store.get(`layers/manifests/${known.id}.json`);
77
+ // A catalog entry for a layer that is not there is refused by the upload validation, as for everyone.
78
+ if (!stored)
79
+ return;
80
+ text = decoder.decode(await readBytes(stored.body));
81
+ }
82
+ let layer;
83
+ try {
84
+ layer = JSON.parse(text);
85
+ }
86
+ catch {
87
+ return;
88
+ }
89
+ const violation = criticScopeViolation(layer ?? {});
90
+ if (violation)
91
+ throw new Refusal(403, violation);
92
+ }
58
93
  /**
59
94
  * The store protocol: the seven operations of an object store over HTTP, for what the server keeps. A client
60
95
  * that publishes to it uses the same code that publishes to a folder or a bucket. It differs from a bucket in
@@ -134,7 +169,8 @@ export function storeApi(options) {
134
169
  return new Response(stored.body, { headers });
135
170
  });
136
171
  app.put('/*', async (c) => {
137
- const verdict = auth.check(c.req.raw, 'publish');
172
+ // A critic may upload too, but only what a critic publishes (see `assertCriticMayStore`).
173
+ const verdict = auth.check(c.req.raw, 'critic');
138
174
  if (!verdict.ok)
139
175
  return refuse(c, verdict.status, verdict.message);
140
176
  const name = key(c);
@@ -143,6 +179,8 @@ export function storeApi(options) {
143
179
  try {
144
180
  const bytes = await readLimited(c.req.raw, options.maxObjectBytes);
145
181
  return await exclusive(name, async () => {
182
+ if (verdict.identity.role === 'critic')
183
+ await assertCriticMayStore(name, bytes, store);
146
184
  const kind = await validateUpload(name, bytes, store, layer => { recent.set(layer.id, layer); if (recent.size > 8)
147
185
  recent.delete(recent.keys().next().value); });
148
186
  // A blob's name is the digest of what is in it, which was just checked: one that is there is this one, however it was compressed.
package/docs/agents.md CHANGED
@@ -103,6 +103,7 @@ The complete public export map is generated from the package manifest (wildcards
103
103
  @crvouga/atlas/vitest
104
104
  @crvouga/atlas/schema
105
105
  @crvouga/atlas/server
106
+ @crvouga/atlas/critics
106
107
  @crvouga/atlas/reports/*
107
108
  @crvouga/atlas/schemas/*
108
109
  @crvouga/atlas/openapi.json
@@ -0,0 +1,190 @@
1
+ # Critics: automated reviewers that mark which UIs need polish
2
+
3
+ A **critic** looks at one captured UI (an image and what is known about it) and says how polished it is, and ideally
4
+ where and why it is not. A critic may be a pretrained model that gives a score, a vision-capable language model that
5
+ follows a rubric, or a classic measure such as a contrast ratio. All three are the same thing to Atlas, through one small
6
+ contract, so people review the likeliest problems first.
7
+
8
+ **Critics are producers that publish layers.** Atlas supplies the contract, the runner, the evidence format and the
9
+ credential. It never executes a model on the server and never holds a provider key. A critique is not a test result and
10
+ is not human review: it never changes whether a report passes.
11
+
12
+ - [What is available](#what-is-available)
13
+ - [The critic contract](#the-critic-contract)
14
+ - [A critique is a layer](#a-critique-is-a-layer)
15
+ - [Running critics](#running-critics)
16
+ - [State-aware judging, calibration and modes](#state-aware-judging-calibration-and-modes)
17
+ - [Authentication and authorization](#authentication-and-authorization)
18
+ - [Credentials](#credentials)
19
+ - [What is not here yet](#what-is-not-here-yet)
20
+
21
+ ## What is available
22
+
23
+ Import `@crvouga/atlas/critics`. It needs no model, no provider SDK and no network; a critic brings its own.
24
+
25
+ | You want | Use |
26
+ | --- | --- |
27
+ | Define a critic | `defineCritic` (the contract below) |
28
+ | Judge many images with caching, retries, rate limits, timeouts and budgets | `runCritic` |
29
+ | Store the opinions | `critiqueLayer`, `publishCritiques` (a layer of parts of kind `critique`) |
30
+ | Read them back | `readCritique`, `critiquesOfLayer`, `critiqueCurrency` |
31
+ | Read a score in context, measure a threshold, decide an exit code | `relativeVerdict`, `calibrate`, `gateDecision` |
32
+ | Keep keys out of configuration and logs | `parseCredentialRef`, `resolveCredential`, `assertNoLiteralCredentials`, `createRedactor` |
33
+ | Let a token publish critiques and nothing else | the `critic` role (below) |
34
+
35
+ The JSON Schema of the evidence is [`critique.schema.json`](../packages/schema/schemas/critique.schema.json). The
36
+ function-level reference is generated from the code: [critics](reference/critics.md).
37
+
38
+ ## The critic contract
39
+
40
+ ```ts
41
+ import { defineCritic } from '@crvouga/atlas/critics';
42
+
43
+ export default defineCritic({
44
+ name: 'contrast', version: '1.0.0', kind: 'heuristic', // kind is an open string: model, llm, ml, heuristic…
45
+ describe: () => ({ sendsImagesTo: null, needsSetup: false }),
46
+ evaluate: async ({ image, imageSha256, entry, signal }) => ({
47
+ verdict: { score: measureContrast(image), scale: '0..1, higher is better' }
48
+ })
49
+ });
50
+ ```
51
+
52
+ A critic only judges one image. `describe()` says where images are sent (`sendsImagesTo`, `null` when they never leave
53
+ the machine), the model, and the fixed parameters (temperature, the SHA-256 of the rubric): together with the critic's
54
+ `name` and `version` and the image hash they are the identity of a judgement. `evaluate` returns a `verdict` (a score
55
+ and how to read it), `findings`, or both, and may throw: throw a `RateLimitedError` (with the provider's `Retry-After` in
56
+ milliseconds), an `InvalidOutputError`, or any `CriticError` with your own reason code. The runner records the failure
57
+ and the next run retries it. A failure never becomes a score.
58
+
59
+ ## A critique is a layer
60
+
61
+ A critique is a check inside a part of kind `critique`, in a layer whose `producer` is `critic.<name>`. The check's
62
+ `status` says only whether the critic ran: `passed` a verdict was produced, `failed` the critic could not produce one.
63
+ The opinion is evidence, by the rules of [extending.md](extending.md#the-rules-that-keep-it-compatible-forever), so it
64
+ can never decide a result. It lives in `extensions["atlas.critique"]` and is repeated as standard evidence, so a viewer
65
+ that has never heard of critics still shows it: the score is a measurement named `polish`, and each finding is an
66
+ annotation of type `critique`.
67
+
68
+ ```json
69
+ {
70
+ "producer": "critic.vision-llm",
71
+ "kind": "critique",
72
+ "checks": [{
73
+ "id": "critique/checkout/payment-sheet",
74
+ "status": "passed",
75
+ "measurements": [{ "name": "polish", "value": 0.62, "unit": "score" }],
76
+ "annotations": [{ "type": "critique", "level": "warning", "message": "spacing: Label touches both edges of the button. (suggestion: Increase horizontal padding.)" }],
77
+ "extensions": { "atlas.critique": {
78
+ "schema": 1,
79
+ "subject": { "entryKey": "checkout/payment-sheet", "imageSha256": "…", "state": "overlay" },
80
+ "critic": { "name": "vision-llm", "kind": "llm", "version": "1.0.0" },
81
+ "model": { "id": "…", "provider": "…" },
82
+ "parameters": { "temperature": 0, "rubric": "<sha256 of the rubric>" },
83
+ "verdict": { "score": 0.62, "scale": "0..1, higher is better", "label": "needs-polish", "confidence": 0.7 },
84
+ "findings": [{ "id": "f1", "category": "spacing", "severity": "minor", "region": { "unit": "px", "x": 24, "y": 612, "w": 342, "h": 48 }, "rationale": "Label touches both edges of the button.", "suggestion": "Increase horizontal padding." }]
85
+ } }
86
+ }]
87
+ }
88
+ ```
89
+
90
+ - A single score (a model-based critic) is a valid critique: everything except `schema`, `subject` and `critic` is optional.
91
+ - `kind`, `category`, `severity`, `label` and failure `reason` are open strings, and every object is open. A value or field
92
+ nobody has heard of is kept and shown generically, never rejected.
93
+ - A reader that cannot read the whole extension (a missing `subject`, a score that is not a number, a `schema` version it
94
+ does not know) drops the extension and keeps the check and its status. An unreadable opinion is unknown.
95
+ - **Anchored to pixels.** `subject.imageSha256` is the hash of the image that was judged. When an entry's image changes,
96
+ `critiqueCurrency` says the old critique judged an earlier image: it is shown, never hidden, and the entry counts as not
97
+ yet judged.
98
+ - **Slots.** The producer is the critic's name; the critic and model versions are labels. A newer version of a critic
99
+ replaces an older one's critique of the same entry. Put the version in `parameters` of `critiqueLayer` to compare
100
+ versions side by side instead.
101
+ - **The prompt is recorded as a hash** (`parameters.rubric`), not as text.
102
+ - **Failures are data.** A crash, a timeout, a rate limit that never cleared and an unparseable answer are `failed`
103
+ critiques with `failure.reason` (`error`, `timeout`, `rate-limited`, `invalid-output`, `image-mismatch`…) and a message.
104
+
105
+ **Critiques never change the gate.** Parts of kind `critique` are left out of every count and of `atlas report status`,
106
+ even when they are the only thing recorded (a report of opinions alone has no test results, so the gate says so). A low
107
+ score does not fail it, and neither does a critic that crashed. A project that wants a score to fail something opts in
108
+ with `gateDecision` in `gate` mode, which is computed by the reader from critique data and never changes a check's
109
+ status, a slot or a winner.
110
+
111
+ ## Running critics
112
+
113
+ `runCritic` owns everything around the judgement; a critic only judges one image.
114
+
115
+ - **Caching.** An entry is skipped, without fetching its image, when a critique with a verdict already exists for the same
116
+ critic, version, model, parameters and image hash (`existing`, read with `critiquesOfLayer`). Identical images across
117
+ runs are judged once; only a changed image is judged again. A failed critique is not a cache hit. `force` judges again.
118
+ Layers are content-addressed, so publishing the same outcomes twice is harmless.
119
+ - **Concurrency, timeout, rate limits.** A bounded number of calls in flight. A call over `timeoutMs` is aborted and
120
+ recorded as `timeout`; the run goes on. A rate-limit answer waits at least `Retry-After`, with exponential backoff and
121
+ jitter, for a bounded number of attempts, and then is recorded as `rate-limited`.
122
+ - **Budgets.** `budget: { maxImages, maxTokens, maxCostUsd }`. When one is reached the run stops cleanly and returns what
123
+ it finished with `stoppedBy: 'budget'`. Cost and tokens come from the `usage` a critic reports.
124
+ - **Dry run.** `dryRun` loads and sends nothing and returns what would be judged and skipped, and where images would go
125
+ (`sendsImagesTo`).
126
+ - **Reproducibility.** Results come back in the order of the entries, so the same inputs publish the same layer; a
127
+ deterministic critic gives identical scores on every run. Atlas records what explains a result (critic, version,
128
+ model, rubric hash, parameters) and does not promise that a language model answers the same twice. A re-run adds a
129
+ newer layer instead of changing an old one.
130
+ - **Images may leave the machine.** `describe().sendsImagesTo` is the thing to show a person before a run that sends
131
+ images to a third party, and to require explicit confirmation for.
132
+
133
+ ```ts
134
+ import { critiquesOfLayer, publishCritiques, runCritic } from '@crvouga/atlas/critics';
135
+
136
+ const result = await runCritic({
137
+ critic, entries, loadImage: (entry) => fetchImage(entry),
138
+ existing: layers.flatMap((layer) => critiquesOfLayer(layer).map((found) => found.critique)),
139
+ budget: { maxImages: 200 }, secrets: [providerKey]
140
+ });
141
+ await publishCritiques(store, { critic, outcomes: result.outcomes, recordedAtMs: Date.now() });
142
+ ```
143
+
144
+ ## State-aware judging, calibration and modes
145
+
146
+ A raw score is not absolute: a loading overlay is not a full screen.
147
+
148
+ - `relativeVerdict` reads a score against the project's scores of the **same state kind** (a percentile within the kind)
149
+ and against the same entry's previous image (`delta`), and goes into `verdict.relative`.
150
+ - `calibrate` measures a critic against labelled examples **per state kind**: sample sizes, precision, recall, the
151
+ threshold chosen, and `inSample`, which is true unless held-out examples were given. In-sample figures flatter a critic
152
+ and are labelled as such.
153
+ - `gateDecision` turns scores into an exit code. Modes: `report` (default: shown, always exit 0), `warn` (a warning for each
154
+ score below its threshold, exit 0), `gate` (exit 1 when a threshold is crossed). `gate` is **refused** (exit 2, with the
155
+ reasons) unless every state kind present has a calibration of this critic and version with a threshold, at least 10 bad
156
+ and 10 good examples, and held-out figures. An explicit threshold never waives that evidence.
157
+
158
+ ## Authentication and authorization
159
+
160
+ Run critics against a server that has tokens: a server with no tokens is open and every caller is an administrator, a
161
+ critic included.
162
+
163
+ A token of role `critic` (`ATLAS_CRITIC_TOKEN`, or `role: "critic"` under `auth.tokens`) may do what `read` may, and
164
+ publish critiques, and nothing else. Through the store protocol (`PUT /api/store/…`) it may upload blobs and layers, and
165
+ the catalog entries that make them visible, whose `producer` is `critic.<name>` and whose parts are all of kind
166
+ `critique`. Anything else is `403` naming the rule it broke, and the layer is not stored. It cannot post test results,
167
+ start live runs, change settings or remove anything. `publish` and `admin` tokens may publish critiques too. As for every
168
+ token: send it as `Authorization: Bearer`, never in a URL, never over plain `http` beyond this machine.
169
+
170
+ ## Credentials
171
+
172
+ Developers bring their own models and AI accounts. Configuration names a credential by **reference**
173
+ (`env:SOME_API_KEY`, `file:/run/secrets/key`) and never contains one, so it can be committed;
174
+ `assertNoLiteralCredentials` refuses a configuration with a literal key and says where, never what. Credentials are read at
175
+ run time (`resolveCredential`) and every message the runner records goes through a redactor (`secrets` in `runCritic`,
176
+ `createRedactor`) that removes them, in their URL-encoded and base64 forms too, and anything shaped like a provider key,
177
+ token or JWT. A credential never reaches a layer, a progress event or an error message.
178
+
179
+ ## What is not here yet
180
+
181
+ This release delivers the contract, the evidence format, the runner, state-aware judging and calibration, credential
182
+ handling and the scoped `critic` role. Still to come, tracked in [issue 50](https://github.com/crvouga/atlas/issues/50):
183
+
184
+ - the **catalog read surface** for critics (cursor pagination, `since`, realtime events, images by hash), which builds on
185
+ the UI catalog of [issue 43](https://github.com/crvouga/atlas/issues/43);
186
+ - the **`atlas critic` command** (`list`, `setup`, `doctor`, `run`, `watch`, `review`, `calibrate`), its interactive mode
187
+ and its `--format json` documents;
188
+ - the **built-in UIClip critic** and the **LLM critic** with provider adapters;
189
+ - the **viewer**: a ranked "needs polish" list, finding overlays and agreement across critics. Until then critiques are
190
+ stored, composed and kept out of test counts, and the viewer lists only test results.
package/docs/extending.md CHANGED
@@ -249,6 +249,7 @@ Annotation `type`: `note`, `warning`, `deprecation`, `issue`, `accessibility`, `
249
249
  | A kind of file Atlas has never seen | Attach it with its media type and a `role`. It is stored and offered for download today. |
250
250
  | Structured data of your own | Put it in `extensions` under a name you own. It is kept, and shown as raw data. |
251
251
  | A new annotation type, role or step kind | Use it. Open vocabularies need no registration. |
252
+ | An automated reviewer's opinion of a captured UI | A [critique](critics.md): a part of kind `critique` with the `atlas.critique` extension. It is never counted and never gates. |
252
253
  | Something that should be shown well | Ask for a renderer (below). |
253
254
  | A field every tool should have | Open an issue. It is added as an optional field, and the rules above guarantee nothing breaks. |
254
255