@crvouga/atlas 1.20.0 → 1.22.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 (114) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/dist/core/src/cli.d.ts +2 -2
  3. package/dist/core/src/cli.js +7 -1
  4. package/dist/critics/src/catalog-client.d.ts +91 -0
  5. package/dist/critics/src/catalog-client.js +194 -0
  6. package/dist/critics/src/cli-calibrate.d.ts +4 -0
  7. package/dist/critics/src/cli-calibrate.js +211 -0
  8. package/dist/critics/src/cli-common.d.ts +108 -0
  9. package/dist/critics/src/cli-common.js +155 -0
  10. package/dist/critics/src/cli-review.d.ts +4 -0
  11. package/dist/critics/src/cli-review.js +174 -0
  12. package/dist/critics/src/cli-watch.d.ts +4 -0
  13. package/dist/critics/src/cli-watch.js +191 -0
  14. package/dist/critics/src/cli.d.ts +13 -0
  15. package/dist/critics/src/cli.js +490 -0
  16. package/dist/critics/src/index.d.ts +3 -0
  17. package/dist/critics/src/index.js +3 -0
  18. package/dist/critics/src/judging.d.ts +8 -0
  19. package/dist/critics/src/llm.d.ts +120 -0
  20. package/dist/critics/src/llm.js +266 -0
  21. package/dist/critics/src/marks.d.ts +74 -0
  22. package/dist/critics/src/marks.js +76 -0
  23. package/dist/critics/src/registry.d.ts +125 -0
  24. package/dist/critics/src/registry.js +203 -0
  25. package/dist/critics/src/spool.d.ts +32 -0
  26. package/dist/critics/src/spool.js +129 -0
  27. package/dist/critics/src/uiclip.d.ts +82 -0
  28. package/dist/critics/src/uiclip.js +167 -0
  29. package/dist/reports/src/compose.d.ts +23 -1
  30. package/dist/reports/src/compose.js +29 -19
  31. package/dist/reports/src/critique-review.d.ts +111 -0
  32. package/dist/reports/src/critique-review.js +123 -0
  33. package/dist/reports/src/critique-row.d.ts +24 -0
  34. package/dist/reports/src/critique-row.js +39 -0
  35. package/dist/reports/src/evidence-types.d.ts +5 -0
  36. package/dist/reports/src/layer-merge.js +5 -0
  37. package/dist/reports/src/report-cli.d.ts +13 -0
  38. package/dist/reports/src/report-cli.js +6 -4
  39. package/dist/reports/src/report-index.d.ts +6 -0
  40. package/dist/reports/src/report-index.js +12 -0
  41. package/dist/reports/src/ui-catalog-cli.d.ts +10 -0
  42. package/dist/reports/src/ui-catalog-cli.js +391 -0
  43. package/dist/reports/src/ui-catalog-diff.d.ts +104 -0
  44. package/dist/reports/src/ui-catalog-diff.js +130 -0
  45. package/dist/reports/src/ui-catalog-entries.d.ts +147 -0
  46. package/dist/reports/src/ui-catalog-entries.js +190 -0
  47. package/dist/reports/src/ui-catalog-export.d.ts +82 -0
  48. package/dist/reports/src/ui-catalog-export.js +221 -0
  49. package/dist/reports/src/ui-catalog-publish.js +2 -2
  50. package/dist/reports/src/ui-catalog.d.ts +2 -0
  51. package/dist/reports/src/ui-catalog.js +1 -1
  52. package/dist/reports/src/ui-state.d.ts +2 -0
  53. package/dist/reports/src/ui-state.js +2 -2
  54. package/dist/schema/src/api.d.ts +653 -1
  55. package/dist/schema/src/api.js +44 -0
  56. package/dist/schema/src/cli-results.d.ts +991 -1
  57. package/dist/schema/src/cli-results.js +265 -1
  58. package/dist/schema/src/critique.d.ts +31 -0
  59. package/dist/schema/src/critique.js +26 -0
  60. package/dist/schema/src/server-config.d.ts +3 -3
  61. package/dist/server/src/app.js +28 -1
  62. package/dist/server/src/openapi.js +19 -2
  63. package/docs/agents.md +16 -2
  64. package/docs/critics.md +273 -9
  65. package/docs/extending.md +1 -1
  66. package/docs/reference/cli.md +126 -1
  67. package/docs/reference/core.md +1 -1
  68. package/docs/reference/critics.md +1131 -29
  69. package/docs/reference/http-api.md +40 -0
  70. package/docs/reference/reports.md +855 -0
  71. package/docs/reference/schema.md +197 -1
  72. package/docs/report-contract.md +2 -1
  73. package/docs/server.md +2 -0
  74. package/docs/ui-catalog.md +110 -13
  75. package/docs/ui.md +69 -2
  76. package/llms-full.txt +636 -26
  77. package/llms.txt +1 -1
  78. package/package.json +9 -1
  79. package/packages/critics/README.md +1 -0
  80. package/packages/schema/schemas/critic-calibrate-result.schema.json +274 -0
  81. package/packages/schema/schemas/critic-doctor-result.schema.json +74 -0
  82. package/packages/schema/schemas/critic-list-result.schema.json +90 -0
  83. package/packages/schema/schemas/critic-review-result.schema.json +397 -0
  84. package/packages/schema/schemas/critic-run-result.schema.json +249 -0
  85. package/packages/schema/schemas/critic-setup-result.schema.json +34 -0
  86. package/packages/schema/schemas/critic-watch-result.schema.json +211 -0
  87. package/packages/schema/schemas/critique-mark.schema.json +81 -0
  88. package/packages/schema/schemas/effective-delta.schema.json +337 -0
  89. package/packages/schema/schemas/effective-evidence.schema.json +337 -0
  90. package/packages/schema/schemas/layer-rows.schema.json +337 -0
  91. package/packages/schema/schemas/ui-catalog-diff-result.schema.json +201 -0
  92. package/packages/schema/schemas/ui-catalog-export-result.schema.json +83 -0
  93. package/packages/schema/schemas/ui-catalog-publish-result.schema.json +116 -0
  94. package/packages/schema/schemas/ui-catalog-status.schema.json +197 -0
  95. package/packages/server/openapi.json +6755 -4738
  96. package/packages/visualizer/src/app/AppShell.tsx +3 -1
  97. package/packages/visualizer/src/app/router.tsx +20 -7
  98. package/packages/visualizer/src/catalog/Picture.tsx +32 -0
  99. package/packages/visualizer/src/catalog/UiCatalogCompare.tsx +289 -0
  100. package/packages/visualizer/src/catalog/UiCatalogPage.tsx +18 -28
  101. package/packages/visualizer/src/catalog/history.ts +61 -0
  102. package/packages/visualizer/src/catalog/search.ts +14 -3
  103. package/packages/visualizer/src/components/Header.tsx +16 -4
  104. package/packages/visualizer/src/critiques/CritiquesPage.tsx +402 -0
  105. package/packages/visualizer/src/critiques/FindingOverlay.tsx +50 -0
  106. package/packages/visualizer/src/critiques/ranking.ts +54 -0
  107. package/packages/visualizer/src/critiques/search.ts +41 -0
  108. package/packages/visualizer/src/evidence/applicability-view.tsx +11 -7
  109. package/packages/visualizer/src/evidence/behaviors-page.tsx +22 -15
  110. package/packages/visualizer/src/evidence/evidence-context.tsx +2 -1
  111. package/packages/visualizer/src/explorer/Explorer.tsx +5 -0
  112. package/packages/visualizer/src/explorer/TestDetail.tsx +4 -1
  113. package/packages/visualizer/src/explorer/parts.tsx +19 -1
  114. package/packages/visualizer/src/explorer/playlist.tsx +11 -7
@@ -0,0 +1,13 @@
1
+ import { type CriticIo } from './cli-common.js';
2
+ export type { CriticIo } from './cli-common.js';
3
+ /** The text `atlas critic --help` prints. Also the source of the command reference in `docs/reference/cli.md`. */
4
+ export declare const CRITIC_USAGE = "atlas critic \u2014 automated reviewers of the UI catalog\n\n atlas critic list [--config atlas.critics.json] [--format text|json]\n atlas critic setup <name> [--config atlas.critics.json] [--format text|json]\n atlas critic doctor [--server <url>] [--critic <name>]... [--config atlas.critics.json] [--format text|json]\n atlas critic run --critic <name>... ( --all | --unjudged | --since <version> | filters ) [--server <url>]\n [--area a,b] [--profile key] [--state screen,overlay] [--route r] [--q words]\n [--dry-run] [--yes] [--non-interactive] [--force]\n [--max-images n] [--max-tokens n] [--max-cost usd] [--concurrency n] [--timeout-ms n]\n [--mode report|warn|gate] [--threshold state=score]... [--calibration file.json]\n [--spool dir] [--project slug] [--config atlas.critics.json] [--format text|json]\n atlas critic watch --critic <name>... [--server <url>] [--since <version>] [filters] [--yes] [--dry-run]\n [--max-batches n] [--max-images n] [--max-tokens n] [--max-cost usd] [--concurrency n] [--timeout-ms n]\n [--spool dir] [--project slug] [--config atlas.critics.json] [--format text|json]\n atlas critic review [--server <url>] [--critic <name>] [--state kind] [--limit n] [--list] [--non-interactive]\n [--entry <key> ( --accept | --dismiss ) [--finding <id>]... [--note text] [--reviewer name] [--dry-run]]\n [--project slug] [--config atlas.critics.json] [--format text|json]\n atlas critic calibrate <name> ( --good <dir>... --bad <dir>... [--heldout-good <dir>...] [--heldout-bad <dir>...] [--state kind]\n | --from-marks [--server <url>] [--critic-version v] ) [--out file.json] [--dry-run] [--yes]\n [--max-images n] [--concurrency n] [--timeout-ms n] [--config atlas.critics.json] [--format text|json]\n\n A critic is a producer: it runs here (on a laptop, in CI or in a worker), reads the catalog from an Atlas server, and publishes\n what it thinks as layers of critiques. The server never runs a model and never holds a provider key. A critique is an opinion,\n not a test result: it never changes whether a report passes.\n\n Configuration is atlas.critics.json, which may be committed: it names credentials by reference (env:NAME, file:PATH) and a\n literal credential in it is refused. The server is --server, \"server.url\" there, ATLAS_SERVER_URL or an https ATLAS_STORE;\n the token is ATLAS_CRITIC_TOKEN (or ATLAS_TOKEN), never a flag, and is never sent over plain http beyond this machine.\n\n list The critics available and configured: type, whether each is ready or needs setup, and where it sends images.\n setup Provision a critic: fetch and verify a model checkpoint against its pinned SHA-256 (a bad checksum is an error), or\n check that a credential reference resolves. This is the only place anything is downloaded; judging never downloads.\n doctor Check the server is reachable, the token is accepted and has the scope a critic needs (and warn when the server is\n open, so every caller is an administrator), that the server lists the catalog, and for each configured critic its\n model file, dependencies, credential and provider. Each problem is reported separately, with how to fix it.\n run Judge the entries in the chosen scope. An entry that already has a current critique of the same critic, version,\n model, parameters and image is skipped without a call; a changed image is judged again. Failures are recorded\n with a reason code and retried by the next run. Critiques that cannot be sent (the server went away) are kept in\n the spool directory (default .atlas/critic-spool) and sent, byte for byte, by the next invocation.\n --dry-run send and publish nothing: say how many entries would be judged or skipped, and where images would go\n --yes allow sending images to a third party without asking (required without a terminal)\n --max-images n stop cleanly after n images; --max-tokens and --max-cost likewise, from what critics report\n --mode report (default): scores are shown and the exit code is 0; warn: also list scores below their threshold;\n gate: exit 1 when a score is below its state kind's threshold, refused (exit 2) unless a calibration\n with held-out evidence of this critic and version is given with --calibration\n\n watch A worker: run in a loop over the catalog. It catches up on connecting, judges new or changed entries as they arrive (a picture\n already judged is skipped without a call), publishes as it goes, keeps what the server cannot take and sends it next time.\n It stops on Ctrl-C, after --max-batches batches, or when a budget (for the whole watch) is spent, and prints the version to\n resume from (--since). It reports; it never gates. --dry-run reads the catalog once and sends and publishes nothing.\n review The ranked \"needs polish\" list of one critic (lowest first) with its findings and the marks already made. With a terminal it\n walks through the list and asks: accept (the opinion is right: the UI needs polish), dismiss (it is not), skip. With\n --entry and --accept or --dismiss it records one decision, for the whole opinion or for --finding ids. A mark is a layer\n under critic.review.<reviewer> (--reviewer, ATLAS_REVIEWER); it needs no more power than a critic token and never changes\n a result, a count or the gate.\n calibrate Judge labelled pictures (--good and --bad directories; a subdirectory's name is the state kind of the pictures in it) and/or\n read the whole-opinion marks reviewers made (--from-marks), and measure the critic per state kind: sample sizes, precision,\n recall, the threshold, and whether the figures are in-sample. --heldout-* pictures are never used to choose the threshold.\n --out writes the calibration that run --calibration reads. Nothing is published.\n\n --format json prints one document (schemas: critic-list-result, critic-setup-result, critic-doctor-result, critic-run-result,\n critic-watch-result, critic-review-result, critic-calibrate-result).\n A command that is wrong is {\"ok\":false,\"error\":{\"message\":\u2026,\"code\":\"usage\"}} on standard error.\n Exit codes: 0 ok, 1 the answer is no (a critic failed, a gate crossed, a problem found), 2 the command was wrong.\n";
5
+ /**
6
+ * `atlas critic …`. Returns the exit code: 0 worked, 1 the answer is no. Throws on a mistake in the command (a missing flag, a
7
+ * gate refused, a configuration that holds a credential), which {@link runCriticCommand} reports and turns into exit code 2.
8
+ *
9
+ * @param argv the arguments after `critic`.
10
+ */
11
+ export declare function criticMain(argv: readonly string[], io: CriticIo): Promise<number>;
12
+ /** `atlas critic …` for a terminal: a mistake in the command is a message on stderr (a JSON document with `--format json`) and exit code 2. */
13
+ export declare function runCriticCommand(argv: readonly string[], io?: Partial<CriticIo>): Promise<number>;
@@ -0,0 +1,490 @@
1
+ /**
2
+ * @module The `atlas critic` command
3
+ *
4
+ * `atlas critic list | setup | doctor | run | watch | review | calibrate`: run automated reviewers of the UI catalog on this machine and publish what they
5
+ * think as critiques. The server stores and shows; it never runs a model and never holds a provider key. `criticMain` takes its
6
+ * arguments, environment, working directory, clock and a prompt as parameters and returns the exit code, so it is driven the same
7
+ * from a terminal, CI and tests; `runCriticCommand` is the terminal wrapper.
8
+ *
9
+ * Interactive when there is a terminal: missing choices are asked for, a run that sends images to a third party names the
10
+ * destination and asks for confirmation. Without a terminal (or with `--non-interactive`, or `--format json`) nothing is ever asked:
11
+ * every choice is a flag, a missing one exits 2 and names the flags, and sending images to a third party needs `--yes`.
12
+ *
13
+ * Exit codes: 0 it worked, 1 it ran and the answer is no (a critic failed on an entry, a gate was crossed, a check of `doctor`
14
+ * found a problem), 2 the command itself was wrong (a missing flag, a gate refused for lack of evidence).
15
+ *
16
+ * @see docs/critics.md#the-atlas-critic-command
17
+ */
18
+ import { readFile } from 'node:fs/promises';
19
+ import path from 'node:path';
20
+ import { httpStore } from '../../reports/src/http-store.js';
21
+ import { ServerInfoSchema } from '../../schema/src/api.js';
22
+ import { createCatalogClient, CatalogClientError } from './catalog-client.js';
23
+ import { calibrateCommand } from './cli-calibrate.js';
24
+ import { connection, defaultAsk, judgeEntries, list, numberFlag, populationOf, positionals, print, redactIn, scopeOf, value, values, wantsJson } from './cli-common.js';
25
+ import { reviewCommand } from './cli-review.js';
26
+ import { watchCommand } from './cli-watch.js';
27
+ import { gateDecision } from './judging.js';
28
+ import { buildCritic, loadCriticsConfig } from './registry.js';
29
+ import { flushSpool, pendingInSpool, spoolingStore } from './spool.js';
30
+ /** The text `atlas critic --help` prints. Also the source of the command reference in `docs/reference/cli.md`. */
31
+ export const CRITIC_USAGE = `atlas critic — automated reviewers of the UI catalog
32
+
33
+ atlas critic list [--config atlas.critics.json] [--format text|json]
34
+ atlas critic setup <name> [--config atlas.critics.json] [--format text|json]
35
+ atlas critic doctor [--server <url>] [--critic <name>]... [--config atlas.critics.json] [--format text|json]
36
+ atlas critic run --critic <name>... ( --all | --unjudged | --since <version> | filters ) [--server <url>]
37
+ [--area a,b] [--profile key] [--state screen,overlay] [--route r] [--q words]
38
+ [--dry-run] [--yes] [--non-interactive] [--force]
39
+ [--max-images n] [--max-tokens n] [--max-cost usd] [--concurrency n] [--timeout-ms n]
40
+ [--mode report|warn|gate] [--threshold state=score]... [--calibration file.json]
41
+ [--spool dir] [--project slug] [--config atlas.critics.json] [--format text|json]
42
+ atlas critic watch --critic <name>... [--server <url>] [--since <version>] [filters] [--yes] [--dry-run]
43
+ [--max-batches n] [--max-images n] [--max-tokens n] [--max-cost usd] [--concurrency n] [--timeout-ms n]
44
+ [--spool dir] [--project slug] [--config atlas.critics.json] [--format text|json]
45
+ atlas critic review [--server <url>] [--critic <name>] [--state kind] [--limit n] [--list] [--non-interactive]
46
+ [--entry <key> ( --accept | --dismiss ) [--finding <id>]... [--note text] [--reviewer name] [--dry-run]]
47
+ [--project slug] [--config atlas.critics.json] [--format text|json]
48
+ atlas critic calibrate <name> ( --good <dir>... --bad <dir>... [--heldout-good <dir>...] [--heldout-bad <dir>...] [--state kind]
49
+ | --from-marks [--server <url>] [--critic-version v] ) [--out file.json] [--dry-run] [--yes]
50
+ [--max-images n] [--concurrency n] [--timeout-ms n] [--config atlas.critics.json] [--format text|json]
51
+
52
+ A critic is a producer: it runs here (on a laptop, in CI or in a worker), reads the catalog from an Atlas server, and publishes
53
+ what it thinks as layers of critiques. The server never runs a model and never holds a provider key. A critique is an opinion,
54
+ not a test result: it never changes whether a report passes.
55
+
56
+ Configuration is atlas.critics.json, which may be committed: it names credentials by reference (env:NAME, file:PATH) and a
57
+ literal credential in it is refused. The server is --server, "server.url" there, ATLAS_SERVER_URL or an https ATLAS_STORE;
58
+ the token is ATLAS_CRITIC_TOKEN (or ATLAS_TOKEN), never a flag, and is never sent over plain http beyond this machine.
59
+
60
+ list The critics available and configured: type, whether each is ready or needs setup, and where it sends images.
61
+ setup Provision a critic: fetch and verify a model checkpoint against its pinned SHA-256 (a bad checksum is an error), or
62
+ check that a credential reference resolves. This is the only place anything is downloaded; judging never downloads.
63
+ doctor Check the server is reachable, the token is accepted and has the scope a critic needs (and warn when the server is
64
+ open, so every caller is an administrator), that the server lists the catalog, and for each configured critic its
65
+ model file, dependencies, credential and provider. Each problem is reported separately, with how to fix it.
66
+ run Judge the entries in the chosen scope. An entry that already has a current critique of the same critic, version,
67
+ model, parameters and image is skipped without a call; a changed image is judged again. Failures are recorded
68
+ with a reason code and retried by the next run. Critiques that cannot be sent (the server went away) are kept in
69
+ the spool directory (default .atlas/critic-spool) and sent, byte for byte, by the next invocation.
70
+ --dry-run send and publish nothing: say how many entries would be judged or skipped, and where images would go
71
+ --yes allow sending images to a third party without asking (required without a terminal)
72
+ --max-images n stop cleanly after n images; --max-tokens and --max-cost likewise, from what critics report
73
+ --mode report (default): scores are shown and the exit code is 0; warn: also list scores below their threshold;
74
+ gate: exit 1 when a score is below its state kind's threshold, refused (exit 2) unless a calibration
75
+ with held-out evidence of this critic and version is given with --calibration
76
+
77
+ watch A worker: run in a loop over the catalog. It catches up on connecting, judges new or changed entries as they arrive (a picture
78
+ already judged is skipped without a call), publishes as it goes, keeps what the server cannot take and sends it next time.
79
+ It stops on Ctrl-C, after --max-batches batches, or when a budget (for the whole watch) is spent, and prints the version to
80
+ resume from (--since). It reports; it never gates. --dry-run reads the catalog once and sends and publishes nothing.
81
+ review The ranked "needs polish" list of one critic (lowest first) with its findings and the marks already made. With a terminal it
82
+ walks through the list and asks: accept (the opinion is right: the UI needs polish), dismiss (it is not), skip. With
83
+ --entry and --accept or --dismiss it records one decision, for the whole opinion or for --finding ids. A mark is a layer
84
+ under critic.review.<reviewer> (--reviewer, ATLAS_REVIEWER); it needs no more power than a critic token and never changes
85
+ a result, a count or the gate.
86
+ calibrate Judge labelled pictures (--good and --bad directories; a subdirectory's name is the state kind of the pictures in it) and/or
87
+ read the whole-opinion marks reviewers made (--from-marks), and measure the critic per state kind: sample sizes, precision,
88
+ recall, the threshold, and whether the figures are in-sample. --heldout-* pictures are never used to choose the threshold.
89
+ --out writes the calibration that run --calibration reads. Nothing is published.
90
+
91
+ --format json prints one document (schemas: critic-list-result, critic-setup-result, critic-doctor-result, critic-run-result,
92
+ critic-watch-result, critic-review-result, critic-calibrate-result).
93
+ A command that is wrong is {"ok":false,"error":{"message":…,"code":"usage"}} on standard error.
94
+ Exit codes: 0 ok, 1 the answer is no (a critic failed, a gate crossed, a problem found), 2 the command was wrong.
95
+ `;
96
+ function parseThresholds(argv) {
97
+ const out = {};
98
+ for (const pair of values(argv, 'threshold')) {
99
+ const at = pair.indexOf('=');
100
+ const score = Number(pair.slice(at + 1));
101
+ if (at < 1 || !Number.isFinite(score))
102
+ throw new Error(`--threshold takes state=score, such as screen=0.6 (got "${pair}")`);
103
+ out[pair.slice(0, at)] = score;
104
+ }
105
+ return out;
106
+ }
107
+ /** `atlas critic list`. */
108
+ async function list_(argv, io, loaded) {
109
+ const rows = [];
110
+ for (const name of Object.keys(loaded.config.critics).sort()) {
111
+ const entry = loaded.config.critics[name];
112
+ try {
113
+ const built = await buildCritic(loaded, name, io);
114
+ const description = built.critic.describe();
115
+ const problems = (await built.inspect()).filter(item => item.status === 'problem');
116
+ rows.push({
117
+ name, type: entry.type, kind: built.critic.kind ?? null, version: built.critic.version,
118
+ status: problems.length ? (problems.some(item => item.aspect === 'model-file') ? 'needs-setup' : 'misconfigured') : 'ready',
119
+ sendsImagesTo: description.sendsImagesTo, model: description.model?.id ?? null,
120
+ ...(problems.length ? { detail: redactIn(problems.map(item => `${item.detail}${item.fix ? ` (${item.fix})` : ''}`).join('; '), built.secrets) } : {})
121
+ });
122
+ }
123
+ catch (error) {
124
+ rows.push({ name, type: entry.type, kind: null, version: null, status: 'misconfigured', sendsImagesTo: null, model: null, detail: error instanceof Error ? error.message.split('\n')[0] : String(error) });
125
+ }
126
+ }
127
+ for (const type of ['uiclip', 'llm']) {
128
+ if (!Object.values(loaded.config.critics).some(entry => entry.type === type)) {
129
+ rows.push({ name: type, type: 'not-configured', kind: null, version: null, status: 'not-configured', sendsImagesTo: null, model: null, detail: `Built in. Add a "${type}" entry under "critics" in atlas.critics.json to use it` });
130
+ }
131
+ }
132
+ if (wantsJson(argv)) {
133
+ print(io, { ok: true, config: loaded.file ? path.relative(io.cwd ?? process.cwd(), loaded.file) || path.basename(loaded.file) : null, critics: rows });
134
+ return 0;
135
+ }
136
+ io.out(`${loaded.file ? `Critics in ${path.basename(loaded.file)}` : 'No atlas.critics.json here'}\n`);
137
+ for (const row of rows)
138
+ io.out(` ${row.status === 'ready' ? '✓' : row.status === 'not-configured' ? '·' : '✗'} ${row.name.padEnd(14)} ${String(row.type).padEnd(14)} ${row.status.padEnd(14)} ${row.sendsImagesTo ? `images go to ${row.sendsImagesTo}` : row.status === 'not-configured' ? '' : 'images stay on this machine'}${row.detail ? `\n → ${row.detail}` : ''}\n`);
139
+ return 0;
140
+ }
141
+ /** `atlas critic setup <name>`. */
142
+ async function setup_(argv, io, loaded) {
143
+ const [name] = positionals(argv);
144
+ if (!name)
145
+ throw new Error(`setup needs the name of a critic: ${Object.keys(loaded.config.critics).join(', ') || 'none are configured in atlas.critics.json'}`);
146
+ const built = await buildCritic(loaded, name, io);
147
+ const type = loaded.config.critics[name].type;
148
+ try {
149
+ const done = await built.setup();
150
+ if (wantsJson(argv))
151
+ print(io, { ok: true, critic: name, type, details: done.details });
152
+ else
153
+ for (const line of done.details)
154
+ io.out(`✓ ${line}\n`);
155
+ return 0;
156
+ }
157
+ catch (error) {
158
+ const message = redactIn(error instanceof Error ? error.message : String(error), built.secrets);
159
+ if (wantsJson(argv))
160
+ print(io, { ok: false, critic: name, type, details: [], error: message });
161
+ else
162
+ io.out(`✗ ${message}\n`);
163
+ return 1;
164
+ }
165
+ }
166
+ /** `atlas critic doctor`. */
167
+ async function doctor_(argv, io, loaded) {
168
+ const env = io.env ?? process.env;
169
+ const checks = [];
170
+ const add = (check) => checks.push(check);
171
+ let role = null, open = null, serverLabel = null;
172
+ const wanted = list(argv, 'critic');
173
+ const names = wanted.length ? wanted : Object.keys(loaded.config.critics);
174
+ let target = null;
175
+ try {
176
+ target = await connection(argv, loaded, env);
177
+ }
178
+ catch (error) {
179
+ add({ name: 'server', status: 'problem', detail: error instanceof Error ? error.message : String(error), fix: 'Use an https address, or put the token in the environment for a server on this machine' });
180
+ }
181
+ if (!target && !checks.length)
182
+ add({ name: 'server', status: 'problem', detail: 'No server is configured', fix: 'Pass --server <url>, set "server.url" in atlas.critics.json, or export ATLAS_SERVER_URL' });
183
+ if (target) {
184
+ serverLabel = target.url;
185
+ const doFetch = io.fetch ?? fetch;
186
+ const headers = target.token ? { authorization: `Bearer ${target.token}` } : {};
187
+ let response = null;
188
+ try {
189
+ response = await doFetch(`${target.url}/api/server`, { headers, signal: AbortSignal.timeout(10_000) });
190
+ }
191
+ catch (error) {
192
+ add({ name: 'server', status: 'problem', detail: `${target.url} cannot be reached (${error instanceof Error ? error.message : 'error'})`, fix: 'Check the address, that the server is running, and the network' });
193
+ }
194
+ let authorized = false;
195
+ if (response) {
196
+ if (response.status === 401 || response.status === 403) {
197
+ add({ name: 'server', status: 'ok', detail: `${target.url} is reachable` });
198
+ add({ name: 'token', status: 'problem', detail: target.token ? 'The server did not accept this token' : 'The server wants a token and none was given', fix: 'Set ATLAS_CRITIC_TOKEN to a token with role critic (the server operator creates one with ATLAS_CRITIC_TOKEN or auth.tokens)' });
199
+ await response.body?.cancel();
200
+ }
201
+ else if (!response.ok) {
202
+ add({ name: 'server', status: 'problem', detail: `${target.url} answered ${response.status}`, fix: 'Check that this is an Atlas server, and that it is running' });
203
+ await response.body?.cancel();
204
+ }
205
+ else {
206
+ const info = ServerInfoSchema.safeParse(await response.json().catch(() => null));
207
+ if (!info.success)
208
+ add({ name: 'server', status: 'problem', detail: `${target.url} did not answer like an Atlas server`, fix: 'Check the address' });
209
+ else {
210
+ authorized = true;
211
+ role = info.data.role;
212
+ open = info.data.open;
213
+ add({ name: 'server', status: 'ok', detail: `${target.url} is reachable${info.data.version ? ` (Atlas ${info.data.version})` : ''}` });
214
+ if (open)
215
+ add({ name: 'scope', status: 'warning', detail: 'The server has no tokens, so every caller is an administrator, critics included', fix: 'Give the server tokens (ATLAS_CRITIC_TOKEN for critics, ATLAS_PUBLISH_TOKEN for CI) so a critic can publish critiques and nothing else' });
216
+ else if (info.data.role === 'critic')
217
+ add({ name: 'scope', status: 'ok', detail: 'The token may read the catalog and publish critiques, and nothing else' });
218
+ else if (info.data.role === 'read')
219
+ add({ name: 'scope', status: 'problem', detail: 'The token may read but not publish critiques', fix: 'Use a token with role critic' });
220
+ else
221
+ add({ name: 'scope', status: 'warning', detail: `The token has role ${info.data.role}, which can do more than a critic needs (it could publish test results or change settings)`, fix: 'Use a token with role critic' });
222
+ if (!info.data.capabilities.catalogEntries)
223
+ add({ name: 'catalog', status: 'problem', detail: 'This server cannot list the catalog for critics', fix: 'Upgrade the server to a release with GET /api/reports/ui-catalog/entries' });
224
+ }
225
+ }
226
+ }
227
+ if (authorized && checks.every(check => check.name !== 'catalog')) {
228
+ try {
229
+ const page = await createCatalogClient({ url: target.url, ...(target.token ? { token: target.token } : {}), ...(target.project ? { project: target.project } : {}), ...(io.fetch ? { fetch: io.fetch } : {}), retries: 0 }).page({ limit: 1 });
230
+ add({ name: 'catalog', status: 'ok', detail: `${page.total} captured ${page.total === 1 ? 'entry' : 'entries'} can be judged${page.unjudgeable ? `; ${page.unjudgeable} captured without a stored picture cannot` : ''}` });
231
+ }
232
+ catch (error) {
233
+ add({ name: 'catalog', status: 'problem', detail: error instanceof CatalogClientError ? error.message : 'The catalog could not be read', fix: 'Check the token and the server version' });
234
+ }
235
+ }
236
+ }
237
+ const spool = path.resolve(loaded.directory, value(argv, 'spool') ?? loaded.config.spool ?? '.atlas/critic-spool');
238
+ const waiting = await pendingInSpool(spool);
239
+ add(waiting ? { name: 'spool', status: 'warning', detail: `${waiting} object${waiting === 1 ? '' : 's'} of critiques that could not be sent wait in ${path.relative(io.cwd ?? process.cwd(), spool) || spool}`, fix: 'The next `atlas critic run` sends them' } : { name: 'spool', status: 'ok', detail: 'Nothing is waiting to be sent' });
240
+ for (const name of names) {
241
+ let built;
242
+ try {
243
+ built = await buildCritic(loaded, name, io);
244
+ }
245
+ catch (error) {
246
+ const message = error instanceof Error ? error.message.split('\n')[0] : String(error);
247
+ add({ name: `${name}:${/credential/i.test(message) ? 'credential' : 'configuration'}`, status: 'problem', detail: message, fix: /not set/.test(message) ? `Export the variable that ${name} names` : 'Fix atlas.critics.json' });
248
+ continue;
249
+ }
250
+ let found;
251
+ try {
252
+ found = await built.inspect();
253
+ }
254
+ catch (error) {
255
+ found = [{ aspect: 'inspect', status: 'problem', detail: error instanceof Error ? error.message : String(error) }];
256
+ }
257
+ for (const item of found)
258
+ add({ name: `${name}:${item.aspect}`, status: item.status, detail: redactIn(item.detail, built.secrets), ...(item.fix ? { fix: item.fix } : {}) });
259
+ }
260
+ if (wanted.length)
261
+ for (const name of wanted)
262
+ if (!loaded.config.critics[name])
263
+ add({ name: `${name}:configuration`, status: 'problem', detail: `No critic named "${name}" is configured`, fix: 'Add it under "critics" in atlas.critics.json' });
264
+ const ok = checks.every(check => check.status !== 'problem');
265
+ if (wantsJson(argv)) {
266
+ print(io, { ok, server: serverLabel, role, open, checks });
267
+ return ok ? 0 : 1;
268
+ }
269
+ for (const check of checks) {
270
+ io.out(` ${check.status === 'ok' ? '✓' : check.status === 'warning' ? '!' : '✗'} ${check.name.padEnd(22)} ${check.detail}\n`);
271
+ if (check.status !== 'ok' && check.fix)
272
+ io.out(` → ${check.fix}\n`);
273
+ }
274
+ io.out(ok ? 'No problems found.\n' : 'Something needs attention (see →).\n');
275
+ return ok ? 0 : 1;
276
+ }
277
+ /** `atlas critic run`. */
278
+ async function run_(argv, io, loaded) {
279
+ const env = io.env ?? process.env;
280
+ const json = wantsJson(argv);
281
+ const interactive = !argv.includes('--non-interactive') && !json && (io.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY));
282
+ const ask = io.ask ?? defaultAsk;
283
+ const dryRun = argv.includes('--dry-run');
284
+ const clock = io.clock ?? Date.now;
285
+ let names = list(argv, 'critic');
286
+ let { query, chosen } = scopeOf(argv);
287
+ let target = await connection(argv, loaded, env);
288
+ const hasScope = () => Object.keys(chosen).length > 0;
289
+ const missing = () => [...(names.length ? [] : ['--critic <name>']), ...(target ? [] : ['--server <url>']), ...(hasScope() ? [] : ['a scope: --all, --unjudged, --since <version>, or a filter such as --area or --state'])];
290
+ if (missing().length && interactive) {
291
+ if (!names.length) {
292
+ const configured = Object.keys(loaded.config.critics);
293
+ names = (await ask(`Which critic${configured.length ? ` (${configured.join(', ')})` : ''}? `)).split(/[,\s]+/).filter(Boolean);
294
+ }
295
+ if (!target) {
296
+ const url = (await ask('Atlas server address? ')).trim();
297
+ if (url)
298
+ target = { url: url.replace(/\/+$/, ''), ...(env.ATLAS_CRITIC_TOKEN || env.ATLAS_TOKEN ? { token: (env.ATLAS_CRITIC_TOKEN || env.ATLAS_TOKEN) } : {}) };
299
+ }
300
+ if (!hasScope()) {
301
+ const answer = (await ask('Scope: judge (a)ll entries, or only those (u)njudged by this critic? ')).trim().toLowerCase();
302
+ if (answer.startsWith('a'))
303
+ chosen = { ...chosen, all: 'true' };
304
+ else if (answer.startsWith('u'))
305
+ chosen = { ...chosen, unjudged: 'true' };
306
+ }
307
+ }
308
+ if (missing().length)
309
+ throw new Error(`run needs ${missing().join(', ')}. Nothing was asked: ${interactive ? 'an answer was empty' : 'there is no terminal (or --non-interactive / --format json was given)'}`);
310
+ const server = target;
311
+ const mode = (value(argv, 'mode') ?? 'report');
312
+ if (!['report', 'warn', 'gate'].includes(mode))
313
+ throw new Error(`--mode takes report|warn|gate (got "${mode}")`);
314
+ const thresholds = parseThresholds(argv);
315
+ let calibration;
316
+ if (value(argv, 'calibration')) {
317
+ try {
318
+ calibration = JSON.parse(await readFile(path.resolve(io.cwd ?? process.cwd(), value(argv, 'calibration')), 'utf8'));
319
+ }
320
+ catch {
321
+ throw new Error(`--calibration ${value(argv, 'calibration')} cannot be read as a calibration document`);
322
+ }
323
+ }
324
+ const budget = { ...(numberFlag(argv, 'max-images', true) ? { maxImages: numberFlag(argv, 'max-images', true) } : {}), ...(numberFlag(argv, 'max-tokens', true) ? { maxTokens: numberFlag(argv, 'max-tokens', true) } : {}), ...(numberFlag(argv, 'max-cost') ? { maxCostUsd: numberFlag(argv, 'max-cost') } : {}) };
325
+ const concurrency = numberFlag(argv, 'concurrency', true), timeoutMs = numberFlag(argv, 'timeout-ms', true);
326
+ const builts = [];
327
+ for (const name of names)
328
+ builts.push(await buildCritic(loaded, name, io));
329
+ const secrets = builts.flatMap(built => built.secrets);
330
+ const client = createCatalogClient({ url: server.url, ...(server.token ? { token: server.token } : {}), ...(server.project ? { project: server.project } : {}), ...(io.fetch ? { fetch: io.fetch } : {}), ...(io.sleep ? { sleep: io.sleep } : {}) });
331
+ const spoolDir = path.resolve(loaded.directory, value(argv, 'spool') ?? loaded.config.spool ?? '.atlas/critic-spool');
332
+ const base = httpStore({ url: server.url, ...(server.token ? { token: server.token } : {}), ...(server.project ? { project: server.project } : {}), ...(io.fetch ? { fetch: io.fetch } : {}), ...(io.sleep ? { sleep: io.sleep } : {}) });
333
+ const warnings = [];
334
+ // What an earlier run could not send goes first, byte for byte; a dry run publishes nothing, so it sends nothing either.
335
+ let flushed = 0;
336
+ if (!dryRun) {
337
+ const sent = await flushSpool(base, spoolDir);
338
+ flushed = sent.sent;
339
+ if (sent.error)
340
+ warnings.push(`${sent.remaining} spooled object${sent.remaining === 1 ? '' : 's'} could not be sent yet: ${redactIn(sent.error, secrets)}`);
341
+ }
342
+ const store = spoolingStore(base, spoolDir);
343
+ const scopeQuery = { ...query };
344
+ const results = [];
345
+ for (const built of builts) {
346
+ const name = built.critic.name;
347
+ const wholeQuery = chosen.unjudged ? { ...scopeQuery, unjudgedBy: { name, version: built.critic.version } } : scopeQuery;
348
+ const read = await client.read(wholeQuery);
349
+ if (!read.sinceHonored)
350
+ warnings.push(`The server does not remember version ${chosen.since}: the whole catalog was read`);
351
+ for (const notice of read.notices)
352
+ warnings.push(notice);
353
+ // `gate` is refused before anything is spent when the evidence is not there.
354
+ if (mode === 'gate') {
355
+ const early = gateDecision({ mode, critic: built.critic, scores: read.entries.map(entry => ({ entryKey: entry.key, stateKind: entry.state, score: null })), ...(calibration ? { calibration } : {}), thresholds });
356
+ if (early.exitCode === 2)
357
+ throw new Error(`gate was refused for ${name}@${built.critic.version}:\n${early.refusal.map(line => ` ${line}`).join('\n')}`);
358
+ }
359
+ const description = built.critic.describe();
360
+ if (!dryRun) {
361
+ await built.ready();
362
+ if (description.sendsImagesTo !== null) {
363
+ const planned = read.entries.length;
364
+ if (!argv.includes('--yes')) {
365
+ if (!interactive)
366
+ throw new Error(`${name} sends images to ${description.sendsImagesTo}: pass --yes to allow it (nothing was sent)`);
367
+ const answer = await ask(`${name} will send up to ${planned} image${planned === 1 ? '' : 's'} to ${description.sendsImagesTo}. Continue? [y/N] `);
368
+ if (!/^y(es)?$/i.test(answer.trim()))
369
+ throw new Error(`Stopped: ${name} would send images to ${description.sendsImagesTo} and that was not confirmed (nothing was sent)`);
370
+ }
371
+ }
372
+ }
373
+ // A raw score is read against the project's scores of the same state kind, and against the entry's previous image.
374
+ const whole = Object.keys(query).length === 0 && !chosen.unjudged ? read : await client.read({});
375
+ const population = populationOf(whole.entries, name);
376
+ const { result, outcomes, layer } = await judgeEntries({
377
+ built, entries: read.entries, client, store, population, dryRun, ...(argv.includes('--force') ? { force: true } : {}),
378
+ budget, secrets, clock, ...(concurrency ? { concurrency } : {}), ...(timeoutMs ? { timeoutMs } : {}),
379
+ ...(json || dryRun ? {} : { onProgress: (event) => io.out(` ${event.result === 'judged' ? '✓' : event.result === 'skipped' ? '·' : '✗'} ${event.entryKey} (${event.judged} judged, ${event.skipped} skipped, ${event.failed} failed, ${event.waiting} waiting)\n`) })
380
+ });
381
+ const current = new Map();
382
+ for (const entry of read.entries) {
383
+ const found = entry.critiques.find(c => c.currency === 'current' && c.critique.critic.name === name && c.critique.verdict?.score !== undefined);
384
+ current.set(entry.key, found?.critique.verdict?.score ?? null);
385
+ }
386
+ for (const outcome of outcomes)
387
+ current.set(outcome.entry.key, outcome.critique.verdict?.score ?? null);
388
+ const decision = gateDecision({ mode, critic: built.critic, scores: read.entries.map(entry => ({ entryKey: entry.key, stateKind: entry.state, score: current.get(entry.key) ?? null })), ...(calibration ? { calibration } : {}), thresholds });
389
+ results.push({ built, result, entries: read.entries.length, decision, layer, critiques: outcomes.length, failures: outcomes.filter(o => o.critique.failure).map(o => ({ entry: o.entry.key, reason: o.critique.failure.reason })) });
390
+ }
391
+ const pending = await pendingInSpool(spoolDir);
392
+ if (store.spooled() > 0)
393
+ warnings.push(`The server could not be reached: ${store.spooled()} object${store.spooled() === 1 ? '' : 's'} of critiques were kept in ${path.relative(io.cwd ?? process.cwd(), spoolDir) || spoolDir} and will be sent by the next invocation`);
394
+ const failed = results.some(item => item.result.failed > 0);
395
+ const crossed = results.some(item => item.decision?.exitCode === 1);
396
+ const ok = !failed && !crossed;
397
+ const document = {
398
+ ok, dryRun, server: server.url, scope: chosen,
399
+ critics: results.map(({ built, result, entries, decision, layer, critiques, failures }) => ({
400
+ critic: built.critic.name, version: built.critic.version, sendsImagesTo: result.sendsImagesTo, entries,
401
+ judged: result.judged, skipped: result.skipped, failed: result.failed, waiting: result.waiting, ...(result.stoppedBy ? { stoppedBy: result.stoppedBy } : {}),
402
+ failures, spent: result.spent, published: { layer, critiques },
403
+ gate: { mode, exitCode: decision?.exitCode ?? 0, below: decision?.below.length ?? 0, warnings: decision?.warnings ?? [], refusal: decision?.refusal ?? [] }
404
+ })),
405
+ spool: { flushed, spooled: store.spooled(), pending },
406
+ warnings: warnings.map(warning => redactIn(warning, secrets))
407
+ };
408
+ if (json) {
409
+ print(io, document);
410
+ return ok ? 0 : 1;
411
+ }
412
+ for (const item of document.critics) {
413
+ io.out(`${dryRun ? 'Would judge' : 'Judged'} with ${item.critic}@${item.version}: ${item.entries} entries in scope, ${dryRun ? `${item.waiting} to judge` : `${item.judged} judged`}, ${item.skipped} skipped (already judged), ${item.failed} failed${item.waiting && !dryRun ? `, ${item.waiting} left` : ''}\n`);
414
+ io.out(item.sendsImagesTo ? ` Images ${dryRun ? 'would go' : 'went'} to ${item.sendsImagesTo}\n` : ' Images stay on this machine\n');
415
+ for (const failure of item.failures)
416
+ io.out(` ✗ ${failure.entry}: ${failure.reason}\n`);
417
+ if (item.stoppedBy === 'budget')
418
+ io.out(' The budget was reached: the run stopped and published what it had finished.\n');
419
+ for (const line of item.gate.warnings)
420
+ io.out(` ! ${line}\n`);
421
+ if (item.published.layer)
422
+ io.out(` Published ${item.published.critiques} critique${item.published.critiques === 1 ? '' : 's'} as layer ${item.published.layer.slice(0, 12)}\n`);
423
+ if (mode === 'gate')
424
+ io.out(item.gate.exitCode === 1 ? ` ✗ gate: ${item.gate.below} score${item.gate.below === 1 ? '' : 's'} below threshold\n` : ' ✓ gate: no score below threshold\n');
425
+ }
426
+ if (flushed)
427
+ io.out(`Sent ${flushed} object${flushed === 1 ? '' : 's'} left by an earlier run\n`);
428
+ for (const warning of document.warnings)
429
+ io.out(`! ${warning}\n`);
430
+ return ok ? 0 : 1;
431
+ }
432
+ /**
433
+ * `atlas critic …`. Returns the exit code: 0 worked, 1 the answer is no. Throws on a mistake in the command (a missing flag, a
434
+ * gate refused, a configuration that holds a credential), which {@link runCriticCommand} reports and turns into exit code 2.
435
+ *
436
+ * @param argv the arguments after `critic`.
437
+ */
438
+ export async function criticMain(argv, io) {
439
+ const [command, ...rest] = argv;
440
+ if (!command || command === '--help' || command === '-h' || rest.includes('--help')) {
441
+ io.out(CRITIC_USAGE);
442
+ return 0;
443
+ }
444
+ if (!['list', 'setup', 'doctor', 'run', 'watch', 'review', 'calibrate'].includes(command)) {
445
+ io.out(CRITIC_USAGE);
446
+ return 2;
447
+ }
448
+ const format = value(rest, 'format');
449
+ if (format !== undefined && !['text', 'json'].includes(format))
450
+ throw new Error(`--format takes text|json (got "${format}")`);
451
+ const loaded = await loadCriticsConfig(io.cwd ?? process.cwd(), value(rest, 'config'));
452
+ if (command === 'list')
453
+ return list_(rest, io, loaded);
454
+ if (command === 'setup')
455
+ return setup_(rest, io, loaded);
456
+ if (command === 'doctor')
457
+ return doctor_(rest, io, loaded);
458
+ if (command === 'watch')
459
+ return watchCommand(rest, io, loaded);
460
+ if (command === 'review')
461
+ return reviewCommand(rest, io, loaded);
462
+ if (command === 'calibrate')
463
+ return calibrateCommand(rest, io, loaded);
464
+ return run_(rest, io, loaded);
465
+ }
466
+ /** `atlas critic …` for a terminal: a mistake in the command is a message on stderr (a JSON document with `--format json`) and exit code 2. */
467
+ export async function runCriticCommand(argv, io = {}) {
468
+ // An interrupt ends a long-running `watch` cleanly: what it finished is published and its summary printed. Other commands keep the default.
469
+ const controller = new AbortController();
470
+ const interrupt = () => controller.abort();
471
+ const watching = argv[0] === 'watch' && !io.signal;
472
+ if (watching) {
473
+ process.once('SIGINT', interrupt);
474
+ process.once('SIGTERM', interrupt);
475
+ }
476
+ try {
477
+ return await criticMain(argv, { out: text => process.stdout.write(text), ...(watching ? { signal: controller.signal } : {}), ...io });
478
+ }
479
+ catch (error) {
480
+ const message = error instanceof Error ? error.message : String(error);
481
+ process.stderr.write(wantsJson(argv) ? `${JSON.stringify({ ok: false, error: { message, code: 'usage' } })}\n` : `atlas critic: ${message}\n`);
482
+ return 2;
483
+ }
484
+ finally {
485
+ if (watching) {
486
+ process.off('SIGINT', interrupt);
487
+ process.off('SIGTERM', interrupt);
488
+ }
489
+ }
490
+ }
@@ -11,11 +11,14 @@
11
11
  *
12
12
  * @see docs/critics.md
13
13
  */
14
+ export * from './catalog-client.js';
14
15
  export * from './contract.js';
15
16
  export * from './credentials.js';
16
17
  export * from './critique.js';
17
18
  export * from './judging.js';
18
19
  export * from './publish.js';
20
+ export * from './registry.js';
19
21
  export * from './runner.js';
20
22
  export * from './scope.js';
23
+ export * from './spool.js';
21
24
  export { CRITIC_PRODUCER_PREFIX, CRITIQUE_EXTENSION, CRITIQUE_PART_KIND, CRITIQUE_SCHEMA_VERSION, CritiqueSchema, type Critique, type CritiqueFinding } from '../../schema/src/critique.js';
@@ -11,11 +11,14 @@
11
11
  *
12
12
  * @see docs/critics.md
13
13
  */
14
+ export * from './catalog-client.js';
14
15
  export * from './contract.js';
15
16
  export * from './credentials.js';
16
17
  export * from './critique.js';
17
18
  export * from './judging.js';
18
19
  export * from './publish.js';
20
+ export * from './registry.js';
19
21
  export * from './runner.js';
20
22
  export * from './scope.js';
23
+ export * from './spool.js';
21
24
  export { CRITIC_PRODUCER_PREFIX, CRITIQUE_EXTENSION, CRITIQUE_PART_KIND, CRITIQUE_SCHEMA_VERSION, CritiqueSchema } from '../../schema/src/critique.js';
@@ -69,11 +69,19 @@ export type KindCalibration = {
69
69
  /** The result of `calibrate`: per state kind, with the critic's provenance. */
70
70
  export type Calibration = {
71
71
  readonly schema: 1;
72
+ /** The critic and its version, and (when `atlas critic calibrate` wrote it) the model and fixed parameters the scores came from. */
72
73
  readonly critic: {
73
74
  readonly name: string;
74
75
  readonly version: string;
76
+ readonly model?: {
77
+ readonly id: string;
78
+ readonly provider?: string;
79
+ };
80
+ readonly parameters?: Readonly<Record<string, unknown>>;
75
81
  };
76
82
  readonly kinds: readonly KindCalibration[];
83
+ /** When it was measured, epoch milliseconds, when `atlas critic calibrate` wrote it. */
84
+ readonly createdAtMs?: number;
77
85
  };
78
86
  /**
79
87
  * Measures a critic against labelled examples, per state kind, and proposes a threshold for each.