@mossbear/protocol 0.1.0-alpha.26

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.
@@ -0,0 +1,1422 @@
1
+ /**
2
+ * Runtime validation schemas using Valibot.
3
+ *
4
+ * Schemas are the single source of truth — TypeScript types in types.ts are
5
+ * derived from these via `v.InferOutput<typeof Schema>`.
6
+ *
7
+ * Callers can use the exported `parse*` wrappers (throw ValiError on invalid
8
+ * input) or call `v.safeParse(Schema, data)` directly for non-throwing checks.
9
+ */
10
+ import * as v from 'valibot';
11
+ // ---------------------------------------------------------------------------
12
+ // Core enum schemas
13
+ // ---------------------------------------------------------------------------
14
+ export const VerdictSchema = v.picklist(['aligned', 'unguided', 'misaligned']);
15
+ export const GuidingTierSchema = v.picklist(['pattern_match', 'cached', 'llm', 'batch']);
16
+ export const PlatformKindSchema = v.picklist([
17
+ 'claude_code',
18
+ 'cursor',
19
+ 'copilot',
20
+ 'windsurf',
21
+ 'other',
22
+ ]);
23
+ export const GuideFileFormatSchema = v.picklist([
24
+ 'agents-md',
25
+ 'agents-dir',
26
+ 'claude-md',
27
+ 'claude-rules',
28
+ 'cursorrules',
29
+ 'cursor-rules',
30
+ 'skill-md',
31
+ ]);
32
+ const NonEmptyStringSchema = v.pipe(v.string(), v.minLength(1));
33
+ const Sha256HexSchema = v.pipe(v.string(), v.regex(/^[a-f0-9]{64}$/i));
34
+ // No eval-provider schemas here. They described the LLM credentials the CLI
35
+ // needed to grade on-device; grading is server-side and the CLI holds no
36
+ // provider config (docs/plans/cli-local-grading-removal-plan.md). The server's
37
+ // own provider settings are environment variables read in
38
+ // `apps/dashboard/src/lib/eval/serverEvalAdapter.ts`, not a wire contract.
39
+ // Auth wire schemas used to live here (ValidateTokenRequest/Response,
40
+ // CliTokenMetadata). The validate-token route never adopted them — it checks
41
+ // `typeof body.token === 'string'` by hand — and nothing else imported them.
42
+ // Deleted rather than kept as a parallel contract that had already drifted
43
+ // from the route's `{ valid }` response.
44
+ // The guide snapshot schemas live further down, after
45
+ // `SafeRelativePosixPathSchema` and `GuideDiscoveryRootSchema`: the bundle now
46
+ // carries the same `(root, path)` pair the import payload does, and a `const`
47
+ // cannot be referenced above its own declaration.
48
+ /** Max serialized size (UTF-8 bytes) of an imported guide file's raw content. */
49
+ export const MAX_RAW_GUIDE_CONTENT_BYTES = 512 * 1024;
50
+ /** Max member files a single skill-md payload may carry. */
51
+ export const MAX_SKILL_MEMBER_FILES = 64;
52
+ const BoundedRawContentSchema = v.pipe(v.string(), v.check((value) => new TextEncoder().encode(value).length <= MAX_RAW_GUIDE_CONTENT_BYTES, `rawContent must be at most ${MAX_RAW_GUIDE_CONTENT_BYTES} bytes`));
53
+ // A path that is safe to re-create on disk and safe to store as identity:
54
+ // forward slashes only, never absolute (POSIX or Windows drive-letter form),
55
+ // no empty / `.` / `..` segments, no control characters. Every path this
56
+ // protocol carries is eventually written into a project tree by the export
57
+ // round-trip, so the rule belongs at the schema boundary rather than in each
58
+ // consumer — CLAUDE.md → Validation.
59
+ //
60
+ // Exported so the member-files read path (server response filtering, CLI
61
+ // defense-in-depth before writing to disk) enforces the identical rule.
62
+ export const SafeRelativePosixPathSchema = v.pipe(v.string(), v.minLength(1), v.maxLength(512), v.check((path) => !path.includes('\\'), 'path must use forward slashes'), v.check((path) => !path.startsWith('/') && !/^[a-zA-Z]:/.test(path), 'path must be relative'), v.check((path) => !/[\u0000-\u001f\u007f]/.test(path), 'path must not contain control characters'), v.check((path) => path
63
+ .split('/')
64
+ .every((segment) => segment !== '' && segment !== '.' && segment !== '..'), 'path must not contain empty, "." or ".." segments'));
65
+ // Member paths are relative to the skill directory, and additionally may never
66
+ // name the skill's own SKILL.md (the payload itself).
67
+ export const SkillMemberPathSchema = v.pipe(SafeRelativePosixPathSchema, v.check((path) => path.toLowerCase() !== 'skill.md', 'path must not shadow the skill’s own SKILL.md'));
68
+ // A sibling file of a skill's SKILL.md (scripts/, references/, assets/,
69
+ // supporting docs), imported alongside it as a guide_file row.
70
+ export const RawSkillMemberFileSchema = v.object({
71
+ path: SkillMemberPathSchema,
72
+ rawContent: BoundedRawContentSchema,
73
+ lastModified: v.pipe(v.number(), v.integer(), v.minValue(0)),
74
+ content_hash: Sha256HexSchema,
75
+ });
76
+ /** Max skipped-member paths a skill-md payload may report alongside `members`. */
77
+ export const MAX_SKILL_SKIPPED_MEMBER_PATHS = 256;
78
+ // Paths (relative to the skill directory) that discovery found but could not
79
+ // import this run — binary/non-UTF-8, oversized, unreadable, or beyond the
80
+ // member cap. Deliberately looser than SkillMemberPathSchema: these describe
81
+ // what is on the user's disk and are only ever string-matched against stored
82
+ // member paths server-side (never re-created as files), so an odd on-disk
83
+ // filename must not fail the whole payload.
84
+ const SkippedMemberPathSchema = v.pipe(v.string(), v.minLength(1), v.maxLength(512));
85
+ /**
86
+ * Which root a discovered guide file's `path` is relative to: the repo checkout
87
+ * (`project`) or the user's homedir (`global`, e.g. `.claude/CLAUDE.md`).
88
+ * Part of the import resolution key — see `RawGuideFilePayloadSchema.root`.
89
+ */
90
+ export const GuideDiscoveryRootSchema = v.picklist(['project', 'global']);
91
+ // ---------------------------------------------------------------------------
92
+ // Guide snapshot schemas
93
+ // ---------------------------------------------------------------------------
94
+ export const GuideSnapshotSchema = v.object({
95
+ id: v.string(),
96
+ title: v.string(),
97
+ content: v.string(),
98
+ enabled: v.boolean(),
99
+ updatedAt: v.string(),
100
+ // origin is `file:{format}:{path}` for imported guides, null/absent
101
+ // otherwise. contentHash is the SHA-256 of the canonical bytes; the CLI uses
102
+ // the pair to round-trip imported files back to disk without re-encoding.
103
+ //
104
+ // **`origin` is not superseded by `(root, path)`.** Nothing on the write side
105
+ // reconciles on it after this slice, but `resolveRuleFileDelivery` still
106
+ // matches delivery evidence on `(origin, contentHash)`
107
+ // (`apps/dashboard/src/lib/eval/ruleFileDelivery.ts:395-430`), so dropping it
108
+ // would make every guide read as undelivered — silently, since runs still
109
+ // grade and verdicts still appear. It stays as provenance.
110
+ origin: v.optional(v.nullable(v.string())),
111
+ contentHash: v.optional(v.nullable(Sha256HexSchema)),
112
+ // Which scope root `path` is relative to, and the path itself — the write
113
+ // side's half of the identity the read side already stores on
114
+ // `guide_files.root` / `guide_files.path`
115
+ // (`guide-write-side-identity-plan.md` → "Carry `root` on every write path").
116
+ //
117
+ // Without them the bundle could not express "global": `origin` is
118
+ // `file:{format}:{path}` with no root component, so every writer resolved it
119
+ // against the repo and a guide imported from `~/.claude/CLAUDE.md` came back
120
+ // down as `<repo>/.claude/CLAUDE.md`.
121
+ //
122
+ // **Both optional and nullable, and their absence is not an error.** A guide
123
+ // authored in the dashboard has no path; a row imported before the columns
124
+ // existed has neither. Such a guide keeps resolving from `origin` alone —
125
+ // today's behaviour — rather than becoming undeliverable. A missing `root` is
126
+ // read as `project`, which is what every pre-existing row meant.
127
+ root: v.optional(v.nullable(GuideDiscoveryRootSchema)),
128
+ path: v.optional(v.nullable(SafeRelativePosixPathSchema)),
129
+ });
130
+ export const GuideBundleResponseSchema = v.object({
131
+ guides: v.array(GuideSnapshotSchema),
132
+ pulledAt: v.string(),
133
+ });
134
+ export const RawGuideFilePayloadSchema = v.pipe(v.object({
135
+ // Relative to the discovery root the CLI walked (`payloadPath`,
136
+ // `packages/cli/src/rules/discover.ts:29-30`). Two things depend on it
137
+ // being a safe relative path, which is why it is parsed here rather than
138
+ // guarded downstream:
139
+ //
140
+ // - it is embedded verbatim in the guide's `origin` (`file:{format}:{path}`)
141
+ // and parsed back out to place the file on disk. The writer that does so
142
+ // is now the docs phase's projection resolver
143
+ // (`packages/cli/src/docs/guide-projection.ts`), which rejects anything
144
+ // absolute, traversing or uncontained — so an unsafe path stores a guide
145
+ // that can never round-trip. (`mossbear guides export` was the original
146
+ // writer and skipped such a path *silently*; it was deleted by
147
+ // `unified-item-sync` Slice 5.);
148
+ // - it is stored as `guide_files.path`, the column import reconciles on
149
+ // (`guide-versioned-identity-plan.md` → Decision 4), and identity must
150
+ // not be keyable on a value the exporter refuses to honour.
151
+ path: SafeRelativePosixPathSchema,
152
+ // Which discovery root `path` is relative to — `guide-versioned-identity-plan.md`
153
+ // → Decision 6. Without it, `(repoId, path)` collapses a project skill onto
154
+ // its `~/.claude/skills` twin: `.claude/skills` is in both of the CLI's root
155
+ // lists and both scans emit the same relative string, while `repoId` is
156
+ // envelope-level so both files carry the same one.
157
+ //
158
+ // **Optional, and its absence is not an error** — a CLI predating this field
159
+ // sends none, exactly as it may send no `repoId`. A payload missing either
160
+ // does not participate in reconciliation and keeps the content-hash insert
161
+ // behaviour it was created under, rather than being defaulted to `'project'`:
162
+ // guessing would let an old client's `--global` file adopt a project row.
163
+ root: v.optional(GuideDiscoveryRootSchema),
164
+ format: GuideFileFormatSchema,
165
+ rawContent: BoundedRawContentSchema,
166
+ lastModified: v.pipe(v.number(), v.integer(), v.minValue(0)),
167
+ content_hash: Sha256HexSchema,
168
+ // Only meaningful on `skill-md` payloads. `[]` asserts the skill has no
169
+ // member files (stale synced rows are removed server-side); omitting the
170
+ // field means "no member information" and leaves member rows untouched.
171
+ // No CLI caller omits it today — every sender reaches this schema through
172
+ // a directory scan that populates it — but the distinction is load-bearing
173
+ // on the wire and must not collapse: reading an absent `members` as an
174
+ // empty skill would hard-delete a client's member rows.
175
+ members: v.optional(v.pipe(v.array(RawSkillMemberFileSchema), v.maxLength(MAX_SKILL_MEMBER_FILES))),
176
+ /**
177
+ * The body-item version this machine last agreed with the server about for
178
+ * this `(scope, root, path)` — the machine-local baseline
179
+ * (`guide-versioned-identity-plan.md` → slice 2b-B, D10 in
180
+ * `context-bundle-authoring-plan.md`).
181
+ *
182
+ * It is the *client* half of the reconcile precondition, and it exists to
183
+ * answer a question the server cannot: whether the bytes on this disk were
184
+ * edited **from** the version the server currently holds, or from an older
185
+ * one. The server-only guard shipped in 2b-A can see that a dashboard edit
186
+ * exists, never that this machine has already seen it — so a guide the
187
+ * dashboard edited and the user then re-synced to disk forks forever.
188
+ *
189
+ * **Optional, and its absence is not an error.** A CLI predating the
190
+ * baseline store, a fresh machine, or a deleted `~/.mossbear/state/` sends
191
+ * none, and the payload falls back to the server-only guard exactly as it
192
+ * behaves today. Absence therefore degrades to "decline and fork", never to
193
+ * an overwrite; a *stale* value degrades to a reported conflict.
194
+ */
195
+ expectedVersion: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))),
196
+ // Only alongside `members`: member paths present on disk but not imported
197
+ // this run. The server must NOT treat their absence from `members` as
198
+ // removal — a transiently unreadable or newly-oversized file would
199
+ // otherwise hard-delete its previously synced row.
200
+ skippedMemberPaths: v.optional(v.pipe(v.array(SkippedMemberPathSchema), v.maxLength(MAX_SKILL_SKIPPED_MEMBER_PATHS))),
201
+ }), v.check((payload) => payload.members === undefined || payload.format === 'skill-md', 'members are only supported on skill-md payloads'), v.check((payload) => payload.skippedMemberPaths === undefined || payload.members !== undefined, 'skippedMemberPaths requires members'),
202
+ // Case-insensitive uniqueness: the export round-trip re-creates these files
203
+ // on disk, where the default macOS/Windows filesystems fold case — two
204
+ // members differing only by case would silently overwrite each other.
205
+ v.check((payload) => payload.members === undefined ||
206
+ new Set(payload.members.map((member) => member.path.toLowerCase())).size ===
207
+ payload.members.length, 'member paths must be unique (case-insensitive)'));
208
+ // A skill guide's member file as served by GET /api/v1/guides/:id/member-files
209
+ // — the export round-trip's read side. Lean by design: member bodies are NOT
210
+ // part of the guide bundle (the eval hot path pulls that every session), they
211
+ // are fetched lazily per skill guide at export time.
212
+ export const GuideMemberFileSchema = v.object({
213
+ path: SkillMemberPathSchema,
214
+ content: BoundedRawContentSchema,
215
+ contentHash: Sha256HexSchema,
216
+ });
217
+ export const GuideMemberFilesResponseSchema = v.pipe(v.object({
218
+ memberFiles: v.pipe(v.array(GuideMemberFileSchema), v.maxLength(MAX_SKILL_MEMBER_FILES)),
219
+ }),
220
+ // Mirrors the import payload's rule: on case-folding filesystems two
221
+ // entries differing only by case resolve to one file, and both planning as
222
+ // NEW would let the second silently clobber the first without the
223
+ // OVERWRITE diff prompt. A response that case-collides is malformed.
224
+ v.check((response) => new Set(response.memberFiles.map((file) => file.path.toLowerCase())).size ===
225
+ response.memberFiles.length, 'member file paths must be unique (case-insensitive)'));
226
+ /** Max number of rules accepted in a single `POST /api/rules/import` request. */
227
+ export const MAX_RULES_IMPORT_BATCH = 100;
228
+ /** Opaque checkout digest shared by run logs, manifests, and `runs.repo_id`. */
229
+ export const RepoIdSchema = v.pipe(v.string(), v.regex(/^repo_[0-9a-f]{32}$/, 'repoId must be repo_ followed by 32 hex chars'));
230
+ /** Longest accepted `projectId`. Generated ids are far shorter; this bounds a hand-declared one. */
231
+ const MAX_PROJECT_ID_LENGTH = 128;
232
+ /**
233
+ * A repo's declared portable identity. See `RulesImportRequestSchema.projectId`
234
+ * for why this is looser than {@link RepoIdSchema} — a declared id is
235
+ * human-authored and predates any prefix rule.
236
+ */
237
+ export const ProjectIdSchema = v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(MAX_PROJECT_ID_LENGTH), v.regex(/^[A-Za-z0-9_.-]+$/, 'projectId must contain only letters, digits, underscore, dot or hyphen'));
238
+ // Envelope-only schema: caps request shape/size before the per-item
239
+ // parseRawGuideFilePayload loop validates each rule in detail (preserves
240
+ // index-aware error reporting — see apps/dashboard/app/api/rules/import+api.ts).
241
+ export const RulesImportRequestSchema = v.object({
242
+ rules: v.pipe(v.array(v.unknown()), v.maxLength(MAX_RULES_IMPORT_BATCH)),
243
+ /**
244
+ * Which checkout these files came from — the same opaque digest the hook and
245
+ * the rule manifest already carry (`packages/cli/src/repo-id.ts`,
246
+ * `RuleManifestSchema.repoId`). Envelope-level, not per-file: every file in
247
+ * one import comes from one repo, and the per-file payload stays lean (the
248
+ * "Import endpoint payload" resolved decision).
249
+ *
250
+ * It is what lets the server reconcile an edited file back onto the guide it
251
+ * already created instead of inserting a second one. `origin` alone cannot:
252
+ * it is `file:{format}:{path}` with no repo component, so two checkouts
253
+ * sharing `.agents/code-style.md` would collide.
254
+ *
255
+ * **Optional, and its absence is not an error.** A CLI predating this field
256
+ * sends none, and the server falls back to the content-hash dedupe it always
257
+ * used. Never send a repo id derived anywhere but `repoIdFromRoot` — the
258
+ * digest is keyed by a machine-local secret precisely so the server never
259
+ * learns a path.
260
+ *
261
+ * Shaped by the canonical `RepoIdSchema` (shared with run logs and manifests)
262
+ * rather than a looser local check: a row storing anything but
263
+ * a real digest could never match one, so a malformed identity has to be
264
+ * rejected at the boundary instead of persisted as a permanent orphan.
265
+ */
266
+ repoId: v.optional(RepoIdSchema),
267
+ /**
268
+ * The repo's **portable** identity — `project.id` from the committed
269
+ * `<repo>/.mossbear/config.json` (`guide-write-side-identity` slice 1, shipped in
270
+ * #540). This is what makes `(scope, path)` reconciliation portable, and it is
271
+ * the field `unified-item-sync` slice 2 exists to put on the wire.
272
+ *
273
+ * **Why `repoId` is not enough, even though it is already here.**
274
+ * `repoIdFromRoot` is an HMAC keyed by a machine-local secret
275
+ * (`packages/cli/src/repo-id.ts`), so the same checkout on two machines — or
276
+ * one teammate's clone versus another's — produces two different digests. A
277
+ * guide reconciled on it therefore forks the moment anyone else edits the
278
+ * file, which is the one-way trip this item is closing. `projectId` is
279
+ * declared in a committed file, so every clone agrees on it.
280
+ *
281
+ * **Optional, and its absence is not an error.** A CLI predating this field
282
+ * sends none and the server falls back to `repoId` scoping, which is exactly
283
+ * the behaviour those clients already have. A repo that has never run
284
+ * `mossbear init` also has no declaration, and must keep importing.
285
+ *
286
+ * Deliberately looser than `RepoIdSchema`: a generated id is
287
+ * `proj_`+hex, but a *declared* one is whatever a human committed —
288
+ * `readRepoProjectId` accepts any non-empty trimmed string
289
+ * (`packages/cli/src/docs/repo-config.ts`), and rejecting those here would
290
+ * break a shipped contract. Constrained only as far as it must be to serve as
291
+ * a storage key: no whitespace, no path separators, no control characters,
292
+ * bounded length.
293
+ */
294
+ projectId: v.optional(ProjectIdSchema),
295
+ });
296
+ /**
297
+ * What the server did with one payload, reported per file so the CLI can update
298
+ * its machine-local baseline (`guide-versioned-identity-plan.md` → slice 2b-B).
299
+ *
300
+ * The distinction that matters is not "did something change" but **do disk and
301
+ * server now agree**: `imported`, `updated` and `skipped` all mean they do, and
302
+ * only those license recording a new baseline. `conflict` means the disk bytes
303
+ * did *not* land — recording the server's version for one would hand the next
304
+ * import a matching `expectedVersion` and turn a refused overwrite into an
305
+ * automatic one on the following run, which is the failure this whole precondition
306
+ * exists to prevent.
307
+ */
308
+ export const GuideImportOutcomeSchema = v.picklist([
309
+ 'imported',
310
+ 'updated',
311
+ 'skipped',
312
+ 'conflict',
313
+ ]);
314
+ /**
315
+ * Per-payload import outcome, addressed by the same `(root, path)` pair the
316
+ * server reconciles on so the CLI can key its baseline identically without
317
+ * re-deriving anything.
318
+ *
319
+ * `version` is the body item's version **as the server holds it after this
320
+ * request** — the value a later import should send as `expectedVersion` once
321
+ * disk and server agree. It is reported for a `conflict` too, deliberately: the
322
+ * conflict report needs it to offer explicit resolution, and reporting it is not
323
+ * the same as recording it.
324
+ *
325
+ * Entries are omitted for a payload the server could not address at all (no
326
+ * `root`, or a pre-R0 row): there is no identity to key a baseline on, and
327
+ * inventing one would let a later import claim a precondition it never observed.
328
+ */
329
+ export const GuideImportResultSchema = v.object({
330
+ root: GuideDiscoveryRootSchema,
331
+ path: SafeRelativePosixPathSchema,
332
+ outcome: GuideImportOutcomeSchema,
333
+ version: v.pipe(v.number(), v.integer(), v.minValue(1)),
334
+ });
335
+ // Deletion signal for a previously imported guide file that disappeared from
336
+ // disk. The dashboard disables (sets active=false) any row whose origin matches
337
+ // `file:{format}:{path}` — the row is retained so historical actions are not
338
+ // orphaned, and so re-creating the file can flip it back on.
339
+ //
340
+ // **That last part is not true today.** The import endpoint's lookup has no
341
+ // `active` predicate, so restoring a deleted file matches the disabled row's
342
+ // hash and is skipped — the row stays off. Reactivation arrives with
343
+ // reconciliation (`guide-versioned-identity` slice 2), which resolves the row
344
+ // by `(userId, repoId, origin)` rather than by hash.
345
+ //
346
+ // **No first-party emitter as of 2026-08-11.** `mossbear rules watch` was this
347
+ // signal's only caller and was deleted (`guide-versioned-identity-plan.md` →
348
+ // "Watch removed"), so nothing in the CLI now derives a deletion from a
349
+ // vanished file. Schema and route stay: Decision 3 of that plan keeps exactly
350
+ // these `(userId, origin)` → `active=false` semantics for unshared
351
+ // repo-authored guides, and whatever re-introduces the signal must address it
352
+ // by item id rather than reviving the origin path.
353
+ export const RawGuideFileDeletionSchema = v.object({
354
+ path: v.pipe(v.string(), v.minLength(1)),
355
+ format: GuideFileFormatSchema,
356
+ });
357
+ /** Max number of deletions accepted in a single `POST /api/rules/deletions` request. */
358
+ export const MAX_RULE_DELETIONS_BATCH = 500;
359
+ // Envelope-only schema, matching RulesImportRequestSchema's shape: caps
360
+ // request shape/size before the per-item parseRawGuideFileDeletion loop in
361
+ // apps/dashboard/app/api/rules/deletions+api.ts validates each entry in
362
+ // detail (preserves its index-aware error reporting).
363
+ export const RuleDeletionsRequestSchema = v.object({
364
+ deletions: v.pipe(v.array(v.unknown()), v.maxLength(MAX_RULE_DELETIONS_BATCH)),
365
+ });
366
+ // No RuleDeletionsResponseSchema — the route returns
367
+ // `Response.json({ disabled, skipped })` directly and nothing parsed the
368
+ // response shape.
369
+ // ---------------------------------------------------------------------------
370
+ // Context bundle schemas (docs/plans/context-layer-mcp-inbox-plan.md — Phase 0)
371
+ // ---------------------------------------------------------------------------
372
+ // url-safe slug, unique per user. Lowercase alphanumerics + hyphens, must start
373
+ // with an alphanumeric so it reads cleanly in a URL/path.
374
+ const BundleSlugSchema = v.pipe(v.string(), v.minLength(1), v.maxLength(64), v.regex(/^[a-z0-9][a-z0-9-]*$/, 'slug must be lowercase alphanumeric with hyphens'));
375
+ export const BundleSchema = v.object({
376
+ id: v.string(),
377
+ slug: BundleSlugSchema,
378
+ name: v.string(),
379
+ createdAt: v.string(),
380
+ });
381
+ export const BundleListResponseSchema = v.object({
382
+ bundles: v.array(BundleSchema),
383
+ });
384
+ export const CreateBundleRequestSchema = v.object({
385
+ slug: BundleSlugSchema,
386
+ name: v.pipe(v.string(), v.minLength(1), v.maxLength(200)),
387
+ });
388
+ export const CreateBundleResponseSchema = v.object({
389
+ bundle: BundleSchema,
390
+ });
391
+ // Membership addressing matches the bundle_items composite key: itemType is
392
+ // part of identity, so detach must carry it too — an itemId alone cannot
393
+ // address a `guide` membership.
394
+ export const BundleMemberTypeSchema = v.picklist(['guide_file', 'guide']);
395
+ export const AttachBundleMemberRequestSchema = v.object({
396
+ itemId: v.pipe(v.string(), v.minLength(1)),
397
+ // Defaults to 'guide_file' server-side.
398
+ itemType: v.optional(BundleMemberTypeSchema),
399
+ });
400
+ export const AttachBundleMemberResponseSchema = v.object({
401
+ // False when the item was already a member — attach is idempotent, and the
402
+ // caller (multi-select menu applying a diff) treats both as success.
403
+ attached: v.boolean(),
404
+ });
405
+ export const DetachBundleMemberResponseSchema = v.object({
406
+ detached: v.boolean(),
407
+ });
408
+ // Rename changes the display name only. The slug is the bundle's identity —
409
+ // repos subscribe by slug in `.mossbear/config.json` → `docs.bundles`, and
410
+ // `mossbear pull <slug>` addresses it — so a slug change would silently break
411
+ // every subscribed repo (context-bundle-authoring-plan.md → D14).
412
+ export const RenameBundleRequestSchema = v.object({
413
+ name: v.pipe(v.string(), v.minLength(1), v.maxLength(200)),
414
+ });
415
+ export const RenameBundleResponseSchema = v.object({
416
+ bundle: BundleSchema,
417
+ });
418
+ export const DeleteBundleResponseSchema = v.object({
419
+ deleted: v.boolean(),
420
+ // `movedToInbox` was removed in `inbox-as-view` slice 2. Deleting a bundle no
421
+ // longer re-parents anything: an item that loses its last membership is
422
+ // unfiled, which is what the inbox view shows, rather than stranded. The
423
+ // field could only ever have reported 0 afterwards, and a number that can
424
+ // never be non-zero is a discriminator carrying no decision. Nothing on the
425
+ // CLI wire parsed it — `parseDeleteBundleResponse` has one caller, the
426
+ // dashboard's own `bundlesApi.ts`.
427
+ });
428
+ // ---------------------------------------------------------------------------
429
+ // Context item + inbox schemas (context-layer-mcp-inbox-plan.md — Phase 1a)
430
+ // ---------------------------------------------------------------------------
431
+ // The `type` of an item returned to an agent. A `guide` is a canonical guide
432
+ // row; the rest are guide_files rows (documents, references, assets).
433
+ export const ContextItemTypeSchema = v.picklist([
434
+ 'guide',
435
+ 'document',
436
+ 'reference',
437
+ 'asset',
438
+ ]);
439
+ // The contentType a caller may *create* through the context layer. `guide` is
440
+ // deliberately absent, and stays absent after slice 2b-B made guides pushable:
441
+ // this schema is reached only by POST (create) and the MCP `push_context`
442
+ // tool's create branch — the PUT body carries no contentType at all
443
+ // (`UpdateContextItemRequestSchema` below). So admitting `guide` here could
444
+ // only legalize a *create*, which the endpoint cannot honor: POST always calls
445
+ // `insertBundleGuideFile`, which writes a standalone guide_files row with
446
+ // `guideId: null` and never the `guides` registration a guide needs, so the
447
+ // caller would get a 201 for something that is not a guide. Updating an
448
+ // existing guide needs nothing from this picklist — it is addressed by
449
+ // `itemId` alone (`guide-versioned-identity-plan.md` → Slice 2b-B, the third
450
+ // Codex constraint, resolved as "restrict to itemId-bearing updates").
451
+ export const PushContentTypeSchema = v.picklist(['document', 'reference', 'asset']);
452
+ // Inbox capture is text or an image today (image upload lands in Phase 2b).
453
+ export const InboxContentTypeSchema = v.picklist(['document', 'asset']);
454
+ /** Max serialized size (UTF-8 bytes) of a pushed or captured item's content. */
455
+ export const MAX_CONTEXT_CONTENT_BYTES = 256 * 1024;
456
+ const ContextContentSchema = v.pipe(v.string(), v.check((value) => new TextEncoder().encode(value).length <= MAX_CONTEXT_CONTENT_BYTES, `content must be at most ${MAX_CONTEXT_CONTENT_BYTES} bytes`));
457
+ const ContextItemNameSchema = v.pipe(v.string(), v.minLength(1), v.maxLength(200));
458
+ // The wire ceiling for an in-place update — see UpdateContextItemRequestSchema
459
+ // for why it is the guide bound rather than the context bound.
460
+ const UpdatableContentSchema = v.pipe(v.string(), v.check((value) => new TextEncoder().encode(value).length <= MAX_RAW_GUIDE_CONTENT_BYTES, `content must be at most ${MAX_RAW_GUIDE_CONTENT_BYTES} bytes`));
461
+ // The item shape returned to agents. `content` is nullable because an asset
462
+ // stores an object key elsewhere (Phase 2b), not inline text.
463
+ export const ContextItemSchema = v.object({
464
+ id: v.string(),
465
+ type: ContextItemTypeSchema,
466
+ name: v.string(),
467
+ contentType: v.string(),
468
+ content: v.nullable(v.string()),
469
+ origin: v.optional(v.nullable(v.string())),
470
+ // Monotonic content version of the underlying doc (guide_files), surfaced so a
471
+ // caller can echo it back as an expected-version precondition. See
472
+ // doc-version-history-plan.md.
473
+ //
474
+ // A `guide` item reports the version of its **body item** — the guide_files
475
+ // row holding its content (`guide-versioned-identity-plan.md` → Decision 1).
476
+ // That is the row a guide push updates, so the precondition a caller echoes
477
+ // back is checked against the same counter it read. Guides carried no version
478
+ // until slice 2b-B, which is why this stays optional: a guide whose
479
+ // `body_item_id` is still NULL (created by an old client mid-rollout, or
480
+ // imported before slice 1's backfill) has no version to report, and the CLI
481
+ // declines to push it rather than falling back to last-write-wins.
482
+ version: v.optional(v.number()),
483
+ // Scope root and path, mirroring `GuideSnapshotSchema` — the docs phase
484
+ // projects an item onto disk, and without a root it resolved every one of
485
+ // them against the repo checkout. Every member kind carries the pair: a
486
+ // `guide` since #543, and a `guide_file` member — documents included — since
487
+ // `unified-item-sync` put the columns on the wire and `docs-in-place` slice 0
488
+ // made the docs phase land a document at its own declared path rather than in
489
+ // the bundle directory. The same optional/nullable rule applies throughout:
490
+ // absent means "no declared path", which degrades to the origin-derived
491
+ // projection (guides) or the bundle-directory default (documents) rather than
492
+ // failing.
493
+ root: v.optional(v.nullable(GuideDiscoveryRootSchema)),
494
+ path: v.optional(v.nullable(SafeRelativePosixPathSchema)),
495
+ updatedAt: v.string(),
496
+ });
497
+ export const ContextItemsResponseSchema = v.object({
498
+ items: v.array(ContextItemSchema),
499
+ });
500
+ /** Max number of context item ids a push may cite as its transform sources. */
501
+ export const MAX_TRANSFORM_SOURCE_IDS = 100;
502
+ /**
503
+ * The ids of the caller-owned context items a pushed document was produced
504
+ * from (inbox captures, bundle items — any item the caller owns). Shared
505
+ * between the REST contract and the MCP tool's client-side arg validation so
506
+ * the two can't drift.
507
+ */
508
+ export const SourceItemIdsSchema = v.pipe(v.array(v.pipe(v.string(), v.minLength(1))), v.minLength(1), v.maxLength(MAX_TRANSFORM_SOURCE_IDS));
509
+ // The update-side variant: same element and upper bound, but an empty array is
510
+ // legal and means "clear the stored provenance". See
511
+ // `UpdateContextItemRequestSchema.sourceItemIds` for why the create side keeps
512
+ // the non-empty bound.
513
+ export const UpdatableSourceItemIdsSchema = v.pipe(v.array(v.pipe(v.string(), v.minLength(1))), v.maxLength(MAX_TRANSFORM_SOURCE_IDS));
514
+ // Push a canonical item into a bundle. `itemId` present → update in place (the
515
+ // MCP push maps to PUT); absent → create (maps to POST). The dedicated update
516
+ // schema below is what the PUT route validates.
517
+ //
518
+ // `sourceItemIds` (optional, on create and update alike) cites the context
519
+ // items the pushed document was produced from. It is **provenance**: the server
520
+ // validates the ids against the caller's own items and stores them on the
521
+ // item's metadata. It no longer triggers a second grading pass — that path was
522
+ // removed in `transform-grading-removal`; work an agent does is graded once,
523
+ // through its run.
524
+ export const PushContextRequestSchema = v.object({
525
+ name: ContextItemNameSchema,
526
+ contentType: PushContentTypeSchema,
527
+ content: ContextContentSchema,
528
+ itemId: v.optional(v.string()),
529
+ origin: v.optional(v.nullable(v.string())),
530
+ sourceItemIds: v.optional(SourceItemIdsSchema),
531
+ });
532
+ export const PushContextResponseSchema = v.object({
533
+ item: ContextItemSchema,
534
+ created: v.boolean(),
535
+ });
536
+ // Update a canonical item in place — the edit propagates to every bundle that
537
+ // references it. Both fields are optional so a partial update parses; the route
538
+ // rejects a body that changes nothing.
539
+ export const UpdateContextItemRequestSchema = v.object({
540
+ itemId: v.pipe(v.string(), v.minLength(1)),
541
+ name: v.optional(ContextItemNameSchema),
542
+ // Bounded by the **guide** cap, not the context cap, because an update can
543
+ // target either kind and the two disagree: an imported guide body may be up
544
+ // to `MAX_RAW_GUIDE_CONTENT_BYTES` (512 KiB) while a pushed/captured item
545
+ // caps at `MAX_CONTEXT_CONTENT_BYTES` (256 KiB). Before slice 2b-B that gap
546
+ // was unreachable — guides could not be pushed — and closing it by lowering
547
+ // the import cap would reject guides that already import today, while raising
548
+ // the context cap would double what the context layer accepts for everything.
549
+ // So this is only the wire ceiling (the DoS bound); the *kind-specific* limit
550
+ // is enforced in `updateBundleGuideFileContent`, which is the one place that
551
+ // has already resolved which kind of row it is about to write. Enforcing it
552
+ // in the route instead would be exactly the rule a future caller forgets
553
+ // (Planning Principle 14).
554
+ content: v.optional(UpdatableContentSchema),
555
+ // Optimistic-concurrency precondition: the version the caller last read (from
556
+ // `ContextItemSchema.version`). When present and it no longer matches the
557
+ // current version, the update is rejected with 409 instead of silently
558
+ // clobbering a concurrent write. Omit it to keep last-write-wins behavior.
559
+ expectedVersion: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))),
560
+ // Replaces the item's stored provenance. An update re-derives the document,
561
+ // so the citation records where the item's *current* body came from; leaving
562
+ // the previous ids in place would describe content that no longer exists.
563
+ // This used to be rejected outright, because a transform was graded once at
564
+ // creation — that reason was deleted with the grader
565
+ // (`transform-grading-removal-plan.md`).
566
+ //
567
+ // Three states, which is why this is not `SourceItemIdsSchema`: **omitted**
568
+ // leaves the stored ids alone (a name-only or provenance-preserving edit),
569
+ // a **non-empty** list replaces them, and **`[]` clears them**. Without the
570
+ // empty case an item rewritten from scratch could never shed provenance that
571
+ // no longer describes it — the silently-wrong record this field's own
572
+ // rationale argues against. `POST` keeps the non-empty bound: there is
573
+ // nothing to clear on a create, so `[]` there would only be a second way to
574
+ // say "no sources" alongside omitting the field.
575
+ sourceItemIds: v.optional(UpdatableSourceItemIdsSchema),
576
+ });
577
+ export const UpdateContextItemResponseSchema = v.object({
578
+ item: ContextItemSchema,
579
+ updated: v.boolean(),
580
+ });
581
+ // The same edit addressed by URL instead of by body field. The item-scoped
582
+ // `PUT /api/v1/context/items/:itemId` takes the id from the path, so carrying it
583
+ // in the body as well would let the two disagree and force the route to pick a
584
+ // winner. Derived with `v.omit` rather than re-declared, so the updatable fields
585
+ // and their bounds cannot drift between the two shapes.
586
+ export const UpdateContextItemBodySchema = v.omit(UpdateContextItemRequestSchema, [
587
+ 'itemId',
588
+ ]);
589
+ /** A single canonical item, for the item-scoped read route. */
590
+ export const ContextItemResponseSchema = v.object({
591
+ item: ContextItemSchema,
592
+ });
593
+ // `POST /api/v1/context/items/:itemId/archive`. A boolean, not a count: the
594
+ // route addresses exactly one item and answers 404 when it does not resolve, so
595
+ // there is no "succeeded on 0 of 1" state left for a number to express. That
596
+ // ambiguity is the bug this route exists to remove — the batch inbox endpoint
597
+ // answered `200 {archived: 0}` for an item it declined to touch
598
+ // (`inbox-as-view-plan.md` → Evidence).
599
+ export const ArchiveContextItemResponseSchema = v.object({
600
+ archived: v.boolean(),
601
+ item: ContextItemSchema,
602
+ });
603
+ export const InboxCaptureRequestSchema = v.object({
604
+ contentType: InboxContentTypeSchema,
605
+ content: ContextContentSchema,
606
+ name: v.optional(ContextItemNameSchema),
607
+ source: v.optional(v.string()),
608
+ });
609
+ // An inbox item carries capture provenance on top of the base item shape.
610
+ // `capturedAt` is the underlying row's createdAt, so "last 24h" is a filter on it.
611
+ export const InboxItemSchema = v.object({
612
+ id: v.string(),
613
+ type: ContextItemTypeSchema,
614
+ name: v.string(),
615
+ contentType: v.string(),
616
+ content: v.nullable(v.string()),
617
+ source: v.optional(v.nullable(v.string())),
618
+ // Monotonic content version of the underlying doc, mirroring
619
+ // `ContextItemSchema.version` — an inbox item is a guide_file too, so a caller
620
+ // that edits it via PUT can echo this back as an expected-version precondition.
621
+ version: v.optional(v.number()),
622
+ capturedAt: v.string(),
623
+ updatedAt: v.string(),
624
+ });
625
+ export const InboxCaptureResponseSchema = v.object({
626
+ item: InboxItemSchema,
627
+ });
628
+ export const InboxListResponseSchema = v.object({
629
+ items: v.array(InboxItemSchema),
630
+ });
631
+ /** Max number of ids accepted in a single `POST /api/v1/context/inbox/archive` request. */
632
+ export const MAX_INBOX_ARCHIVE_BATCH = 500;
633
+ export const InboxArchiveRequestSchema = v.object({
634
+ ids: v.pipe(v.array(v.pipe(v.string(), v.minLength(1))), v.minLength(1), v.maxLength(MAX_INBOX_ARCHIVE_BATCH)),
635
+ });
636
+ export const InboxArchiveResponseSchema = v.object({
637
+ archived: v.pipe(v.number(), v.integer(), v.minValue(0)),
638
+ });
639
+ // ---------------------------------------------------------------------------
640
+ // Doc version history (read API)
641
+ // ---------------------------------------------------------------------------
642
+ // One append-only saved version of a context doc (guide_file). Rows are served
643
+ // lazily over REST and never sync through Zero — syncing them would grow every
644
+ // client's initial-sync payload forever. See doc-version-history-plan.md.
645
+ //
646
+ // `authorType` is intentionally an open string, not a picklist: the server owns
647
+ // the closed set ('user' | 'agent' | 'cli'), but a deployed client parses this
648
+ // response strictly, and a picklist would turn any future author kind into a
649
+ // hard parse error on an otherwise valid history read.
650
+ export const ContextItemVersionSchema = v.object({
651
+ id: v.string(),
652
+ // Monotonic per doc; the newest version equals the item's current `version`.
653
+ version: v.pipe(v.number(), v.integer(), v.minValue(1)),
654
+ contentHash: v.string(),
655
+ authorType: v.string(),
656
+ // Non-secret provenance the write path recorded (e.g. bundle slug, capture
657
+ // source). Never a bearer secret — these rows outlive tokens and ride export.
658
+ metadata: v.optional(v.nullable(v.record(v.string(), v.unknown()))),
659
+ createdAt: v.string(),
660
+ });
661
+ // The full version including the snapshotted body, returned by the per-version
662
+ // content fetch. `content` is nullable to stay blob-tier compatible: a future
663
+ // encrypted/blob version stores a hash pointer, not inline text.
664
+ export const ContextItemVersionDetailSchema = v.object({
665
+ id: v.string(),
666
+ version: v.pipe(v.number(), v.integer(), v.minValue(1)),
667
+ content: v.nullable(v.string()),
668
+ contentHash: v.string(),
669
+ authorType: v.string(),
670
+ metadata: v.optional(v.nullable(v.record(v.string(), v.unknown()))),
671
+ createdAt: v.string(),
672
+ });
673
+ // Newest-first page of a doc's version summaries. `nextCursor` is the version
674
+ // number to pass back as `?before=` for the following (older) page, or null
675
+ // once the oldest version has been returned.
676
+ export const ContextItemVersionsResponseSchema = v.object({
677
+ versions: v.array(ContextItemVersionSchema),
678
+ nextCursor: v.nullable(v.number()),
679
+ });
680
+ export const ContextItemVersionResponseSchema = v.object({
681
+ version: ContextItemVersionDetailSchema,
682
+ });
683
+ /** Default and max page size for the version-history list endpoint. */
684
+ export const DEFAULT_VERSIONS_LIMIT = 50;
685
+ export const MAX_VERSIONS_LIMIT = 200;
686
+ // ---------------------------------------------------------------------------
687
+ // Sync payload schemas
688
+ // ---------------------------------------------------------------------------
689
+ const DashboardSyncTierSchema = v.picklist([
690
+ 'pattern_match',
691
+ 'cached',
692
+ 'llm',
693
+ 'batch',
694
+ 'unguided',
695
+ ]);
696
+ const DashboardGuideSuggestionStatusSchema = v.picklist([
697
+ 'pending',
698
+ 'accepted',
699
+ 'dismissed',
700
+ ]);
701
+ const DashboardFeedbackCandidateStatusSchema = v.picklist([
702
+ 'inferred',
703
+ 'confirmed',
704
+ 'dismissed',
705
+ ]);
706
+ const DashboardFeedbackSignalTypeSchema = v.picklist([
707
+ 'correction',
708
+ 'approval',
709
+ 'implied_preference',
710
+ 'stated_preference',
711
+ 'skill_suggestion',
712
+ ]);
713
+ export const ProposalCitationSchema = v.object({
714
+ sessionId: v.string(),
715
+ timestamp: v.optional(v.string()),
716
+ summary: v.string(),
717
+ });
718
+ /** Max serialized size of an action's `metadata` field — bounds storage bloat. */
719
+ export const MAX_ACTION_METADATA_BYTES = 16 * 1024;
720
+ /**
721
+ * Longest accepted CLI version string. Releases are `x.y.z` /
722
+ * `x.y.z-<channel>.<n>`, so this is generous — it exists because the value is
723
+ * client-supplied and lands on every action row, not to constrain the format
724
+ * (ordering already fails closed on anything unparseable).
725
+ */
726
+ const MAX_CLI_VERSION_LENGTH = 64;
727
+ const CliVersionSchema = v.pipe(v.string(), v.nonEmpty(), v.maxLength(MAX_CLI_VERSION_LENGTH));
728
+ /**
729
+ * Identity of the skill an agent loaded, captured by the logger hook from a
730
+ * `Skill` tool call (docs/plans/run-level-grading-plan.md, Slice 0).
731
+ *
732
+ * Two fields because the hook can only ever be sure of one of them:
733
+ *
734
+ * - `name` is `tool_input.skill` verbatim — always present, never qualified.
735
+ * Two skills that share a directory name (`.claude/skills/review` and
736
+ * `.agents/skills/review`) are indistinguishable by it, which is review
737
+ * finding 5 on PR #387.
738
+ * - `path` is the skill's *directory*, relative to the discovery root the
739
+ * guide import walks and in the same posix form its `origin` carries
740
+ * (`.claude/skills/review`). This is the qualified identity: it is exactly
741
+ * `guides.origin`'s path with the trailing `/SKILL.md` removed, so the two
742
+ * sides of the join are the same string rather than two derivations of it.
743
+ * Optional because the hook resolves it by probing the filesystem: a skill
744
+ * that lives somewhere the probe does not look (a plugin skill, a
745
+ * `~/.claude/skills` skill, a session whose cwd is not the repo root) has a
746
+ * name and no path, and matching falls back to the name.
747
+ */
748
+ export const ActionSkillLoadSchema = v.object({
749
+ name: NonEmptyStringSchema,
750
+ path: v.optional(NonEmptyStringSchema),
751
+ });
752
+ /**
753
+ * The `actions.action_type` a skill load is stored under.
754
+ *
755
+ * Deliberately **not** a member of `KNOWN_ACTION_TYPES` below, even though the
756
+ * CLI emits it on purpose. A skill load is evidence about what was in the
757
+ * agent's *context*, not a piece of work anyone should grade — it is an input to
758
+ * `inContextGuides`, never a row the grader judges (`gradeableActions.ts`
759
+ * filters it out). Keeping it out of the known-vocabulary list is what stops it
760
+ * being treated as gradeable work by anything that enumerates that list.
761
+ */
762
+ export const SKILL_LOAD_ACTION_TYPE = 'skill_load';
763
+ /**
764
+ * A machine-shaped label for *why* a tool call failed — `ENOENT`, `TimeoutError`,
765
+ * `permission_denied`.
766
+ *
767
+ * Deliberately not free text. An error *message* is content: it quotes file
768
+ * contents, stderr and third-party data, which is exactly the class
769
+ * `content-sync-privacy` gates and `sanitizeCommand` cannot redact (it targets
770
+ * shell syntax, not prose). A class is metadata, so it rides the same
771
+ * unblocked path as the exit code. The character set admits identifiers and
772
+ * dotted/namespaced codes and nothing else; anything failing it is dropped
773
+ * rather than truncated, because half an error class is not a smaller fact, it
774
+ * is a different one.
775
+ */
776
+ export const ActionErrorClassSchema = v.pipe(v.string(), v.regex(/^[A-Za-z0-9_.:-]{1,32}$/, 'errorClass must be 1-32 chars of [A-Za-z0-9_.:-]'));
777
+ /**
778
+ * Whether a logged tool call actually succeeded
779
+ * (docs/plans/action-evidence-sync-plan.md — the outcome-metadata slice).
780
+ *
781
+ * The logger hook parsed `tool_input` and dropped the `tool_response` beside
782
+ * it, so every action reached the grader looking like it had happened. A failed
783
+ * edit and a successful one rendered identically, and the run grader has no
784
+ * other way to tell them apart.
785
+ *
786
+ * **Absent means unknown, never success.** The hook emits this only from a
787
+ * response shape it recognizes (`extractActionOutcome`), and agent tool
788
+ * responses vary per tool and per platform; a guess here would be worse than
789
+ * silence, because the grader would weigh it. `status` is therefore the only
790
+ * required field, and the whole object is optional everywhere it appears.
791
+ */
792
+ export const ActionOutcomeSchema = v.object({
793
+ status: v.picklist(['ok', 'error']),
794
+ errorClass: v.optional(ActionErrorClassSchema),
795
+ /**
796
+ * Bounded to a signed 16-bit range, which covers every real exit status
797
+ * (0-255, 128+signal, and Node's -1) with room to spare. The bound is a line
798
+ * length bound as much as a validity one: this renders into the grading
799
+ * prompt, where every field is capped.
800
+ */
801
+ exitCode: v.optional(v.pipe(v.number(), v.integer(), v.minValue(-32_768), v.maxValue(32_767))),
802
+ });
803
+ /**
804
+ * {@link ActionOutcomeSchema} with unknown keys refused rather than ignored.
805
+ *
806
+ * Used only by `ActionMetadataSchema`'s boundary check, where the difference
807
+ * matters: see the comment there. Readers parse with the permissive schema,
808
+ * which strips unknown keys on output the way `v.object` always has.
809
+ */
810
+ const StrictActionOutcomeSchema = v.strictObject(ActionOutcomeSchema.entries);
811
+ /**
812
+ * Longest narration field accepted on the wire.
813
+ *
814
+ * Matches `MAX_PROSE_CHARS` in `@mossbear/eval-core`, which is the cap the
815
+ * client-side redactor already applies. Duplicated as a literal rather than
816
+ * imported because the dependency runs the other way — `@mossbear/eval-core`
817
+ * depends on this package — and inverting it to share one constant would make
818
+ * the wire contract depend on the evaluation package. The number is a bound, so
819
+ * a drift between them fails safe in the direction that matters: the server
820
+ * refusing something the client would have sent, never the reverse.
821
+ */
822
+ export const MAX_ACTION_NARRATION_CHARS = 2000;
823
+ /**
824
+ * A code fence at the start of a line, allowing Markdown container prefixes.
825
+ *
826
+ * The exact structural inverse of what `stripCodeBlocks` guarantees: it removes
827
+ * every fenced block by this same rule, so a narration field reaching the server
828
+ * with a line-initial fence in it *cannot* have been produced by a client that
829
+ * ran the pipeline. Rejecting is therefore free of false positives — inline
830
+ * `` `code` `` spans and a mid-sentence "wrap it in ```" are untouched, because
831
+ * neither begins a line.
832
+ *
833
+ * **What this does and does not buy.** It catches the realistic failure — a
834
+ * client whose stripping broke, or one built against the wire schema without
835
+ * reading the class table — before its output lands in a column. It is not a
836
+ * defense against a determined client, which would simply send the same source
837
+ * with no fence around it; the server cannot tell prose from code in general,
838
+ * which is exactly why the class table strips whole *structures* rather than
839
+ * asking a scrubber to recognize code. Claiming more than this would be the
840
+ * "alignment theater" the charter's fourth commitment names.
841
+ */
842
+ const LINE_INITIAL_FENCE = /^[ \t]*(?:>[ \t]?)*(?:`{3,}|~{3,})/m;
843
+ const NarrationTextSchema = v.pipe(v.string(), v.maxLength(MAX_ACTION_NARRATION_CHARS), v.check((value) => !LINE_INITIAL_FENCE.test(value), 'narration must not contain a code block — code is stripped structurally on the client before upload'));
844
+ /**
845
+ * Transcript evidence attached to an action
846
+ * (docs/plans/transcript-content-classes-plan.md — the founder's class call of
847
+ * 2026-08-13).
848
+ *
849
+ * **Two fields, and the omissions are the specification.** The class table
850
+ * decides that user turns and assistant *prose* sync prose-redacted, assistant
851
+ * code blocks are stripped to a language-and-line-count placeholder, and
852
+ * tool-result bodies **never** sync. There is therefore no field here for a
853
+ * tool result, no field for a code-block body, and no field for a diff.
854
+ *
855
+ * **`strictObject`, so a body that carries one is rejected rather than
856
+ * trimmed.** This is the same decision `StrictActionOutcomeSchema` above makes
857
+ * and for a sharper version of the same reason. A permissive `v.object` strips
858
+ * unknown keys on output, which would turn a client sending
859
+ * `narration.toolResult` into a *silently accepted* request: the sender is told
860
+ * it worked, learns nothing, and keeps sending. Trimming is a policy that fails
861
+ * open — it makes an over-sharing client indistinguishable from a correct one.
862
+ * Rejecting makes the class table enforceable against a client the server does
863
+ * not control, which is the only kind of enforcement worth having here; the
864
+ * CLI's own choke point (`packages/cli/src/transcript/narration.ts`) is the
865
+ * first line, not the last.
866
+ *
867
+ * The forward-compatibility cost is the same as `outcome`'s, and so is the
868
+ * ordering that pays it: widen this schema and deploy the server before any
869
+ * CLI starts sending a new narration field.
870
+ */
871
+ export const ActionNarrationSchema = v.strictObject({
872
+ /**
873
+ * The user turn this action followed — prose-redacted on the user's machine.
874
+ *
875
+ * **Not every message with `role: 'user'`.** A tool result is delivered as a
876
+ * user-role message whose content is a `tool_result` block, so the client
877
+ * selects on content-block type rather than role
878
+ * (`packages/cli/src/transcript/messages.ts`). Nothing on this side can tell
879
+ * the difference after the fact, which is why the client selects
880
+ * structurally and this schema constrains the shape rather than the content.
881
+ */
882
+ userText: v.optional(NarrationTextSchema),
883
+ /**
884
+ * Assistant prose adjacent to the tool call — code blocks structurally
885
+ * removed, then prose-redacted, then windowed, all on the user's machine.
886
+ */
887
+ assistantText: v.optional(NarrationTextSchema),
888
+ });
889
+ const ActionMetadataSchema = v.pipe(v.record(v.string(), v.unknown()), v.check((value) => new TextEncoder().encode(JSON.stringify(value)).length <= MAX_ACTION_METADATA_BYTES, `metadata must serialize to at most ${MAX_ACTION_METADATA_BYTES} bytes`),
890
+ // `metadata` is an open record by design (platform-specific context), but the
891
+ // one key the server *reads* has to be trustworthy: `skill` is what resolves
892
+ // a run's loaded skills, so a malformed one must be rejected at the boundary
893
+ // rather than silently ignored downstream.
894
+ v.check((value) => value.skill === undefined || v.is(ActionSkillLoadSchema, value.skill), 'metadata.skill must be { name: string, path?: string }'), v.check((value) => value.repoId === undefined || v.is(RepoIdSchema, value.repoId), 'metadata.repoId must be repo_ followed by 32 hex chars'),
895
+ // Same reasoning as `skill` above: the server reads this one, so a malformed
896
+ // outcome is rejected at the boundary rather than reaching the grader as a
897
+ // half-parsed fact about whether the action succeeded.
898
+ //
899
+ // `v.is` only answers yes/no — it does not substitute the schema's stripped
900
+ // output — and the surrounding `metadata` is an open record that keeps what
901
+ // it was given. A permissive object check would therefore *store* an
902
+ // unrecognized `outcome.message`, which is exactly the free-text error prose
903
+ // this field exists to keep off the wire. `strictObject` is what makes the
904
+ // check mean what it says.
905
+ //
906
+ // The forward-compatibility cost is real and the ordering that pays it:
907
+ // widen this schema (server, deployed first) before any CLI starts sending a
908
+ // new outcome field, or an un-upgraded server rejects the whole action.
909
+ v.check((value) => value.outcome === undefined || v.is(StrictActionOutcomeSchema, value.outcome), 'metadata.outcome must be { status: "ok" | "error", errorClass?: string, exitCode?: number } with no other keys'));
910
+ // Token usage extracted from an agent's own transcript (e.g. Claude Code
911
+ // JSONL), keyed by model. See docs/plans/token-cost-tracking-plan.md — Phase 1
912
+ // stores raw token counts only; cost is derived server-side in Phase 2.
913
+ export const TokenUsageSchema = v.object({
914
+ model: NonEmptyStringSchema,
915
+ provider: v.optional(v.string()),
916
+ inputTokens: v.pipe(v.number(), v.integer(), v.minValue(0)),
917
+ outputTokens: v.pipe(v.number(), v.integer(), v.minValue(0)),
918
+ cacheReadTokens: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
919
+ cacheWriteTokens: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
920
+ });
921
+ export const DashboardRunUsageSchema = v.object({
922
+ runId: v.optional(v.string()),
923
+ sessionId: NonEmptyStringSchema,
924
+ usage: v.array(TokenUsageSchema),
925
+ });
926
+ // Per-skill Tier-1 "load overhead" token usage extracted from an agent's own
927
+ // transcript (docs/plans/token-cost-tracking-plan.md §13.3) — the precise part
928
+ // of skill-cost attribution: the tokens billed to load a Skill/SKILL.md into
929
+ // context, approximated in v1 as the first-load message's usage. Tier 2
930
+ // (heuristic guided-work spans) is not implemented yet.
931
+ export const SkillUsageEntrySchema = v.object({
932
+ skillName: NonEmptyStringSchema,
933
+ model: NonEmptyStringSchema,
934
+ inputTokens: v.pipe(v.number(), v.integer(), v.minValue(0)),
935
+ cacheWriteTokens: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
936
+ });
937
+ export const DashboardSkillUsageSchema = v.object({
938
+ runId: v.optional(v.string()),
939
+ sessionId: NonEmptyStringSchema,
940
+ usage: v.array(SkillUsageEntrySchema),
941
+ });
942
+ /**
943
+ * Bounds on the action fields that reach an LLM prompt verbatim.
944
+ *
945
+ * Every one of these is interpolated into the run grader's action log
946
+ * (`apps/dashboard/src/lib/eval/runGradePrompt.ts`), so an unbounded value is
947
+ * not just storage bloat: it is an authenticated client deciding how much
948
+ * context one nominally-capped action consumes, or making the provider call
949
+ * fail on length over and over while the run stays retryable. The prompt caps
950
+ * them again on the way out — these stop the oversized value being *stored*,
951
+ * which is the only place a cap holds for rows the prompt never renders.
952
+ *
953
+ * Sized well above anything the CLI produces: summaries are capped at 200
954
+ * characters before they leave the machine (`MAX_SUMMARY_CHARS`,
955
+ * `packages/cli/src/action-queue.ts:31`), action types are derived from tool
956
+ * names, and `filePaths` carries at most one entry per action
957
+ * (`toSyncAction`, `packages/cli/src/action-queue.ts:135-147`).
958
+ */
959
+ const MAX_ACTION_ID_LENGTH = 128;
960
+ const MAX_ACTION_TYPE_LENGTH = 128;
961
+ const MAX_ACTION_SUMMARY_LENGTH = 4_000;
962
+ const MAX_ACTION_FILE_PATHS = 100;
963
+ const MAX_ACTION_FILE_PATH_LENGTH = 1_024;
964
+ /**
965
+ * An action id as it may appear in a prompt: bounded, and free of the control
966
+ * characters that would let it break out of the line it is rendered on. Ids are
967
+ * the one field the grader reads back and cites, so they cannot be truncated or
968
+ * sanitized after the fact without silently detaching every citation that used
969
+ * them — the boundary is the only place to reject a bad one.
970
+ */
971
+ const ActionIdSchema = v.pipe(v.string(), v.maxLength(MAX_ACTION_ID_LENGTH), v.regex(/^[^\p{Cc}\p{Cf}\s]*$/u, 'id must not contain whitespace or control characters'));
972
+ export const DashboardSyncActionSchema = v.object({
973
+ id: v.optional(ActionIdSchema),
974
+ adapterId: v.optional(v.string()),
975
+ runId: v.optional(v.string()),
976
+ /**
977
+ * Optional on the wire, but **required to be graded**: runs are the only
978
+ * grading unit, and an action with no session joins no run
979
+ * (docs/plans/run-level-grading-plan.md). The field stays optional because
980
+ * tightening it would 400 a whole batch — a poison pill for any queue file
981
+ * carrying one — and no shipped CLI omits it: the hook always writes a
982
+ * session id, falling back to the literal `'unknown'`
983
+ * (`packages/cli/src/hook-logger.ts:129`), which groups into a run like any
984
+ * other. A direct API caller that omits it gets its actions stored and
985
+ * ungraded, and the server logs `sync.actions_without_session` so the case is
986
+ * visible rather than silent.
987
+ */
988
+ sessionId: v.optional(v.string()),
989
+ actionType: v.pipe(v.string(), v.maxLength(MAX_ACTION_TYPE_LENGTH)),
990
+ summary: v.pipe(v.string(), v.maxLength(MAX_ACTION_SUMMARY_LENGTH)),
991
+ filePaths: v.optional(v.pipe(v.array(v.pipe(v.string(), v.maxLength(MAX_ACTION_FILE_PATH_LENGTH))), v.maxLength(MAX_ACTION_FILE_PATHS))),
992
+ verdict: v.optional(VerdictSchema),
993
+ gradingTier: v.optional(DashboardSyncTierSchema),
994
+ guideIds: v.optional(v.array(v.string())),
995
+ violatedGuideId: v.optional(v.nullable(v.string())),
996
+ evalReasoning: v.optional(v.nullable(v.string())),
997
+ metadata: v.optional(v.nullable(ActionMetadataSchema)),
998
+ /**
999
+ * Transcript evidence for this action. See {@link ActionNarrationSchema}.
1000
+ *
1001
+ * A top-level field rather than a `metadata` key, and that is a decision
1002
+ * rather than a style choice (design review, 2026-08-15): `metadata` is an
1003
+ * open record holding *metadata* under one shared 16 KB budget, and this is
1004
+ * content — the first content an action carries. It lands in its own column
1005
+ * server-side for the same reason.
1006
+ *
1007
+ * **No shipped CLI sends this yet.** Slice 2 builds the extraction and both
1008
+ * halves of the enforcement; Slice 3 adds the column and the disclosure
1009
+ * surface and is what turns it on, so nothing begins leaving a user's machine
1010
+ * a release before the panel describing it.
1011
+ */
1012
+ narration: v.optional(ActionNarrationSchema),
1013
+ /**
1014
+ * Version of the CLI that *produced* this action's verdict.
1015
+ *
1016
+ * **No shipped CLI sends this any more.** Its only writer was `mossbear verdict
1017
+ * record`, whose queue files could outlive an upgrade, which is why the
1018
+ * per-action version was distinguished from the request-level one at all.
1019
+ * That command is gone (docs/plans/cli-local-grading-removal-plan.md) and no
1020
+ * client-side verdict exists to attribute.
1021
+ *
1022
+ * Kept on the wire for one release so an un-upgraded CLI's queued verdicts
1023
+ * are not rejected mid-migration; the field and the `actions.cli_version`
1024
+ * column it feeds are dropped together (`action-cli-version-column-drop`).
1025
+ * For the CLI making the request — which is live, and what
1026
+ * `enforceCliVersionFloor` reads — see `DashboardSyncRequestSchema`.
1027
+ */
1028
+ cliVersion: v.optional(CliVersionSchema),
1029
+ });
1030
+ export const DashboardGuideSuggestionSchema = v.object({
1031
+ id: v.optional(v.string()),
1032
+ content: v.string(),
1033
+ reasoning: v.optional(v.string()),
1034
+ triggerType: v.picklist([
1035
+ 'uncovered',
1036
+ 'ambiguous',
1037
+ 'performance_pattern',
1038
+ 'conversational_feedback',
1039
+ ]),
1040
+ sourceActionIds: v.optional(v.array(v.union([v.string(), ProposalCitationSchema]))),
1041
+ status: v.optional(DashboardGuideSuggestionStatusSchema),
1042
+ });
1043
+ export const DashboardFeedbackCandidateSchema = v.object({
1044
+ id: v.optional(v.string()),
1045
+ signal: v.string(),
1046
+ signalType: DashboardFeedbackSignalTypeSchema,
1047
+ confidence: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(1))),
1048
+ occurrences: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))),
1049
+ status: v.optional(DashboardFeedbackCandidateStatusSchema),
1050
+ });
1051
+ /** Max number of actions accepted in a single `POST /api/sync` request. */
1052
+ export const MAX_SYNC_ACTIONS_BATCH = 500;
1053
+ /** Max number of guideSuggestions/feedbackCandidates/runUsage entries per sync request. */
1054
+ export const MAX_SYNC_AUX_BATCH = 100;
1055
+ /**
1056
+ * Max rule files one delivery manifest enumerates. Well above what a real repo
1057
+ * carries (this one: ~20), and bounded because the manifest rides every
1058
+ * action-carrying batch: an unbounded list would multiply across a chunked
1059
+ * sync. Overflow sets `truncated` rather than being dropped silently.
1060
+ */
1061
+ export const MAX_RULE_MANIFEST_FILES = 500;
1062
+ /**
1063
+ * A rule file observed on disk in the repo `mossbear sync` ran from.
1064
+ *
1065
+ * `path` is relative to that repo root, in exactly the form `payloadPath`
1066
+ * takes at import time (`packages/cli/src/rules/discover.ts`), so
1067
+ * `file:{format}:{path}` reconstructs the `origin` the dashboard stored for
1068
+ * the guide this file produced. That reconstruction is the whole join; nothing
1069
+ * else about the file is needed and nothing else is sent.
1070
+ *
1071
+ * **Never any content.** `contentHash` is the same sha256 the import payload
1072
+ * carries, present so a later slice can tell "delivered" from "delivered but
1073
+ * edited since import" without shipping the bytes again.
1074
+ */
1075
+ export const RuleManifestFileSchema = v.object({
1076
+ path: v.pipe(v.string(), v.minLength(1), v.maxLength(512)),
1077
+ format: GuideFileFormatSchema,
1078
+ contentHash: Sha256HexSchema,
1079
+ });
1080
+ /**
1081
+ * Which rule files were actually on disk in the repo this sync ran from —
1082
+ * observed delivery, replacing the assumption that every non-skill active
1083
+ * guide reached the agent (docs/plans/run-level-grading-plan.md, review
1084
+ * finding 3). Without it, an account with several repos, or a
1085
+ * dashboard-authored guide never exported anywhere, is graded against
1086
+ * instructions the agent never received.
1087
+ *
1088
+ * `repoId` is an opaque, machine-local digest — deliberately not a path, a
1089
+ * remote URL, or a repo name. Its only job is to keep two checkouts on the
1090
+ * same account distinguishable, which is the half of review finding 5 that
1091
+ * repo-root-relative origins cannot express: two repos each holding
1092
+ * `.claude/skills/review` produce byte-identical origins by construction. See
1093
+ * `repoIdFromRoot` (`packages/cli/src/rules/manifest.ts`) for how it is
1094
+ * derived and why that derivation is privacy-safe.
1095
+ */
1096
+ export const RuleFileManifestSchema = v.object({
1097
+ repoId: RepoIdSchema,
1098
+ files: v.pipe(v.array(RuleManifestFileSchema), v.maxLength(MAX_RULE_MANIFEST_FILES)),
1099
+ /**
1100
+ * The repo held more rule files than the cap, so `files` is a prefix. Set
1101
+ * rather than silently trimmed: a manifest read as complete would mark the
1102
+ * omitted files' guides undelivered, which is the exact wrong-verdict
1103
+ * failure this field exists to prevent.
1104
+ */
1105
+ truncated: v.optional(v.boolean()),
1106
+ });
1107
+ export const DashboardSyncRequestSchema = v.object({
1108
+ actions: v.optional(v.pipe(v.array(DashboardSyncActionSchema), v.maxLength(MAX_SYNC_ACTIONS_BATCH))),
1109
+ guideSuggestions: v.optional(v.pipe(v.array(DashboardGuideSuggestionSchema), v.maxLength(MAX_SYNC_AUX_BATCH))),
1110
+ feedbackCandidates: v.optional(v.pipe(v.array(DashboardFeedbackCandidateSchema), v.maxLength(MAX_SYNC_AUX_BATCH))),
1111
+ runUsage: v.optional(v.pipe(v.array(DashboardRunUsageSchema), v.maxLength(MAX_SYNC_AUX_BATCH))),
1112
+ skillUsage: v.optional(v.pipe(v.array(DashboardSkillUsageSchema), v.maxLength(MAX_SYNC_AUX_BATCH))),
1113
+ /**
1114
+ * Version of the CLI making *this request* — the live one, and the only
1115
+ * `cliVersion` a shipped CLI still sends (the per-action field is a
1116
+ * migration leftover; see `DashboardSyncActionSchema`). It sits on the
1117
+ * envelope because the server reads it before touching any row, to
1118
+ * compare against its version floor and reject an incompatible client with
1119
+ * 426 rather than half-ingesting the batch. Optional: clients predating this
1120
+ * field send none, and are never blocked for it.
1121
+ */
1122
+ cliVersion: v.optional(CliVersionSchema),
1123
+ /**
1124
+ * Sessions whose agent context has ended, named by the CLI's `SessionEnd`
1125
+ * hook (`mossbear sync --session-complete`) — never by its per-turn `Stop` hook.
1126
+ * The server closes those sessions' runs; every other session in the payload
1127
+ * stays open.
1128
+ *
1129
+ * Session-keyed rather than a single envelope-level boolean, because one
1130
+ * `mossbear sync` uploads every session with pending actions and chunks them
1131
+ * across several requests. A boolean would close sessions that merely had a
1132
+ * backlog, and would close a session before the batches carrying the rest of
1133
+ * its actions had arrived. The CLI therefore names a session here only on the
1134
+ * final batch that carries that session's actions.
1135
+ *
1136
+ * Deliberately an assertion, not a fact: `SessionEnd` also fires on
1137
+ * `/clear`, `--resume` and `logout`, and a reclaimed cloud container may fire
1138
+ * nothing at all. Closing is therefore reversible — later actions for a
1139
+ * closed run reopen it — and the server's idle sweep, not this field, is what
1140
+ * runs actually depend on being closed by. See
1141
+ * docs/plans/run-level-grading-plan.md Slice 1.
1142
+ */
1143
+ completedSessionIds: v.optional(v.pipe(v.array(NonEmptyStringSchema), v.maxLength(MAX_SYNC_AUX_BATCH))),
1144
+ /**
1145
+ * The rule files present in the repo this sync ran from — see
1146
+ * {@link RuleFileManifestSchema}.
1147
+ *
1148
+ * Envelope-level rather than per-action because it describes the machine and
1149
+ * the checkout, not any one tool call; the server attaches it to the runs it
1150
+ * materializes from this request. Present on every batch that carries
1151
+ * actions, so a session chunked across several requests has the manifest
1152
+ * available wherever its run is touched.
1153
+ *
1154
+ * Optional: a CLI predating this sends none, and the server must read its
1155
+ * absence as "delivery unobserved", never as "nothing was delivered".
1156
+ */
1157
+ ruleManifest: v.optional(RuleFileManifestSchema),
1158
+ });
1159
+ export const ExtractedGuideSuggestionSchema = v.object({
1160
+ id: v.string(),
1161
+ content: v.string(),
1162
+ reasoning: v.string(),
1163
+ triggerType: v.picklist(['conversational_feedback']),
1164
+ confidence: v.pipe(v.number(), v.minValue(0), v.maxValue(1)),
1165
+ occurrences: v.pipe(v.number(), v.integer(), v.minValue(1)),
1166
+ citations: v.array(ProposalCitationSchema),
1167
+ source: PlatformKindSchema,
1168
+ });
1169
+ export const ExtractedSkillSuggestionSchema = v.object({
1170
+ id: v.string(),
1171
+ signal: v.string(),
1172
+ confidence: v.pipe(v.number(), v.minValue(0), v.maxValue(1)),
1173
+ occurrences: v.pipe(v.number(), v.integer(), v.minValue(1)),
1174
+ citations: v.array(ProposalCitationSchema),
1175
+ source: PlatformKindSchema,
1176
+ });
1177
+ export const ExtractedFeedbackBatchSchema = v.object({
1178
+ guideSuggestions: v.array(ExtractedGuideSuggestionSchema),
1179
+ skillSuggestions: v.array(ExtractedSkillSuggestionSchema),
1180
+ });
1181
+ // ---------------------------------------------------------------------------
1182
+ // Agent config schema
1183
+ // ---------------------------------------------------------------------------
1184
+ // AgentConfig is what `mossbear guides project` and `packages/projections` share.
1185
+ // EvalRequest/EvalResult/Finding and ProjectionRequest/ProjectionResult used
1186
+ // to wrap it for the CLI's on-device grader and a projection wire envelope;
1187
+ // both are gone (docs/plans/cli-local-grading-removal-plan.md Open questions)
1188
+ // and had zero consumers outside this package's own tests.
1189
+ export const AgentConfigSchema = v.object({
1190
+ id: v.string(),
1191
+ name: v.string(),
1192
+ description: v.string(),
1193
+ capabilities: v.array(v.string()),
1194
+ meta: v.optional(v.record(v.string(), v.unknown())),
1195
+ });
1196
+ // ---------------------------------------------------------------------------
1197
+ // Action log schemas
1198
+ // ---------------------------------------------------------------------------
1199
+ export const ActionEntrySchema = v.object({
1200
+ /**
1201
+ * Stable identity, assigned by the logger hook when the action happens.
1202
+ *
1203
+ * Serves two jobs at once (docs/plans/action-evidence-sync-plan.md): it is
1204
+ * the `actions.id` a sync sends, so re-syncing the same append-only run log
1205
+ * is a no-op against the server's `onConflictDoNothing`; and it is the
1206
+ * correlation key for joining transcript evidence to an action, which
1207
+ * adjacency cannot do once one assistant message emits several tool calls.
1208
+ *
1209
+ * Optional because run logs written before this field existed must stay
1210
+ * readable. Sync derives an id for those from `(sessionId, line index)`; see
1211
+ * `deriveLegacyActionId`.
1212
+ */
1213
+ id: v.optional(v.string()),
1214
+ timestamp: v.string(),
1215
+ sessionId: v.string(),
1216
+ toolName: v.string(),
1217
+ filePath: v.optional(v.string()),
1218
+ command: v.optional(v.string()),
1219
+ /**
1220
+ * Set only for a `Skill` tool call: which skill the agent loaded.
1221
+ *
1222
+ * This is what makes a skill load ride the action stream instead of the
1223
+ * transcript. `skill_usage` — the other place a loaded skill name is
1224
+ * recorded — is populated by `extractRunUsage`, which returns empty unless
1225
+ * `mossbear sync` is given `--transcripts-dir`, and the hook `mossbear init`
1226
+ * installs never passes one. So the transcript path has no data in a normal
1227
+ * session, while an action definitionally syncs.
1228
+ */
1229
+ skill: v.optional(ActionSkillLoadSchema),
1230
+ /**
1231
+ * Where this session's transcript lives on *this machine*, as the agent
1232
+ * itself reported it.
1233
+ *
1234
+ * **Local-only, and it must stay that way.** `toSyncAction`
1235
+ * (`packages/cli/src/action-queue.ts`) builds the wire payload field by
1236
+ * field and does not include this one; transcripts are disclosed as
1237
+ * local-only (`apps/dashboard/src/lib/privacy/whatSyncs.ts`), and a path is
1238
+ * still a fact about the user's filesystem. It exists so `mossbear sync` can
1239
+ * find the transcript for a session it is already uploading without
1240
+ * searching for it (docs/plans/default-transcript-discovery-plan.md →
1241
+ * "Session resolution").
1242
+ *
1243
+ * Optional three times over: run logs written before this field existed must
1244
+ * stay readable, a hook protocol other than Claude Code's may not send it,
1245
+ * and resolution falls back to the `<sessionId>.jsonl` filename convention
1246
+ * when it is absent or stale.
1247
+ */
1248
+ transcriptPath: v.optional(v.string()),
1249
+ /**
1250
+ * Which checkout produced this action — the same digest as
1251
+ * {@link RuleFileManifestSchema}'s `repoId`, stamped by the logger hook from
1252
+ * the event's `cwd` so a sync in repo A does not attach A's manifest to
1253
+ * sessions that ran in repo B (`run-repo-attribution`).
1254
+ */
1255
+ repoId: v.optional(RepoIdSchema),
1256
+ /**
1257
+ * Whether the tool call succeeded, when the hook could tell.
1258
+ *
1259
+ * Optional twice over: run logs written before this field existed must stay
1260
+ * readable, and a recognized response shape is not guaranteed even now. See
1261
+ * {@link ActionOutcomeSchema} — absent is "unknown", not "fine".
1262
+ */
1263
+ outcome: v.optional(ActionOutcomeSchema),
1264
+ });
1265
+ /**
1266
+ * Action types the CLI maps agent tool calls onto.
1267
+ *
1268
+ * Deliberately **not** a closed schema. `DashboardSyncAction.actionType` stays
1269
+ * an open string so a tool the CLI has never seen still syncs as itself
1270
+ * (`packages/cli/src/action-queue.ts` normalizes an unknown tool name rather
1271
+ * than collapsing it into a catch-all) — a closed picklist would silently drop
1272
+ * those. This list is the *known* vocabulary: what the CLI produces on purpose.
1273
+ *
1274
+ * It lives here because it is a contract between two packages that never import
1275
+ * each other. It used to also be the vocabulary the guide-trigger picker
1276
+ * offered; that picker is gone (docs/plans/run-level-grading-plan.md, Slice 4)
1277
+ * and nothing matches against this list any more — relevance is observed from
1278
+ * what was in the agent's context, not asserted against an action type.
1279
+ *
1280
+ * `SKILL_LOAD_ACTION_TYPE` is the one type the CLI emits on purpose that is
1281
+ * deliberately absent here; see its own comment for why.
1282
+ */
1283
+ const KNOWN_ACTION_TYPES = [
1284
+ 'file_edit',
1285
+ 'file_read',
1286
+ 'shell_command',
1287
+ 'search',
1288
+ 'web_access',
1289
+ ];
1290
+ /** True when `value` is one of the action types the CLI produces on purpose. */
1291
+ export function isKnownActionType(value) {
1292
+ return KNOWN_ACTION_TYPES.includes(value);
1293
+ }
1294
+ // No run-eval result schema here. It was the contract for the CLI's on-device
1295
+ // grader, which is gone (docs/plans/cli-local-grading-removal-plan.md). The
1296
+ // only grader left runs on the server and owns its own output contract in
1297
+ // `apps/dashboard/src/lib/eval/runGradeSchema.ts` — one shape of that fact, not
1298
+ // two packages holding copies that a comment has to keep in step.
1299
+ // ---------------------------------------------------------------------------
1300
+ // Parse wrappers (throw ValiError on invalid input)
1301
+ // ---------------------------------------------------------------------------
1302
+ export function parseAgentConfig(data) {
1303
+ return v.parse(AgentConfigSchema, data);
1304
+ }
1305
+ export function parseGuideSnapshot(data) {
1306
+ return v.parse(GuideSnapshotSchema, data);
1307
+ }
1308
+ export function parseGuideBundleResponse(data) {
1309
+ return v.parse(GuideBundleResponseSchema, data);
1310
+ }
1311
+ export function parseRawGuideFilePayload(data) {
1312
+ return v.parse(RawGuideFilePayloadSchema, data);
1313
+ }
1314
+ export function parseGuideMemberFilesResponse(data) {
1315
+ return v.parse(GuideMemberFilesResponseSchema, data);
1316
+ }
1317
+ export function parseRawGuideFileDeletion(data) {
1318
+ return v.parse(RawGuideFileDeletionSchema, data);
1319
+ }
1320
+ export function parseRuleDeletionsRequest(data) {
1321
+ return v.parse(RuleDeletionsRequestSchema, data);
1322
+ }
1323
+ export function parseRulesImportRequest(data) {
1324
+ return v.parse(RulesImportRequestSchema, data);
1325
+ }
1326
+ export function parseBundle(data) {
1327
+ return v.parse(BundleSchema, data);
1328
+ }
1329
+ export function parseBundleListResponse(data) {
1330
+ return v.parse(BundleListResponseSchema, data);
1331
+ }
1332
+ export function parseCreateBundleRequest(data) {
1333
+ return v.parse(CreateBundleRequestSchema, data);
1334
+ }
1335
+ export function parseCreateBundleResponse(data) {
1336
+ return v.parse(CreateBundleResponseSchema, data);
1337
+ }
1338
+ export function parseAttachBundleMemberRequest(data) {
1339
+ return v.parse(AttachBundleMemberRequestSchema, data);
1340
+ }
1341
+ export function parseAttachBundleMemberResponse(data) {
1342
+ return v.parse(AttachBundleMemberResponseSchema, data);
1343
+ }
1344
+ export function parseDetachBundleMemberResponse(data) {
1345
+ return v.parse(DetachBundleMemberResponseSchema, data);
1346
+ }
1347
+ export function parseRenameBundleRequest(data) {
1348
+ return v.parse(RenameBundleRequestSchema, data);
1349
+ }
1350
+ export function parseRenameBundleResponse(data) {
1351
+ return v.parse(RenameBundleResponseSchema, data);
1352
+ }
1353
+ export function parseDeleteBundleResponse(data) {
1354
+ return v.parse(DeleteBundleResponseSchema, data);
1355
+ }
1356
+ export function parseContextItem(data) {
1357
+ return v.parse(ContextItemSchema, data);
1358
+ }
1359
+ export function parseContextItemsResponse(data) {
1360
+ return v.parse(ContextItemsResponseSchema, data);
1361
+ }
1362
+ export function parsePushContextRequest(data) {
1363
+ return v.parse(PushContextRequestSchema, data);
1364
+ }
1365
+ export function parsePushContextResponse(data) {
1366
+ return v.parse(PushContextResponseSchema, data);
1367
+ }
1368
+ export function parseUpdateContextItemRequest(data) {
1369
+ return v.parse(UpdateContextItemRequestSchema, data);
1370
+ }
1371
+ export function parseUpdateContextItemResponse(data) {
1372
+ return v.parse(UpdateContextItemResponseSchema, data);
1373
+ }
1374
+ export function parseUpdateContextItemBody(data) {
1375
+ return v.parse(UpdateContextItemBodySchema, data);
1376
+ }
1377
+ export function parseContextItemResponse(data) {
1378
+ return v.parse(ContextItemResponseSchema, data);
1379
+ }
1380
+ export function parseArchiveContextItemResponse(data) {
1381
+ return v.parse(ArchiveContextItemResponseSchema, data);
1382
+ }
1383
+ export function parseInboxCaptureRequest(data) {
1384
+ return v.parse(InboxCaptureRequestSchema, data);
1385
+ }
1386
+ export function parseInboxCaptureResponse(data) {
1387
+ return v.parse(InboxCaptureResponseSchema, data);
1388
+ }
1389
+ export function parseInboxListResponse(data) {
1390
+ return v.parse(InboxListResponseSchema, data);
1391
+ }
1392
+ export function parseContextItemVersionsResponse(data) {
1393
+ return v.parse(ContextItemVersionsResponseSchema, data);
1394
+ }
1395
+ export function parseContextItemVersionResponse(data) {
1396
+ return v.parse(ContextItemVersionResponseSchema, data);
1397
+ }
1398
+ export function parseInboxArchiveRequest(data) {
1399
+ return v.parse(InboxArchiveRequestSchema, data);
1400
+ }
1401
+ export function parseInboxArchiveResponse(data) {
1402
+ return v.parse(InboxArchiveResponseSchema, data);
1403
+ }
1404
+ export function parseDashboardSyncRequest(data) {
1405
+ return v.parse(DashboardSyncRequestSchema, data);
1406
+ }
1407
+ export function parseProposalCitation(data) {
1408
+ return v.parse(ProposalCitationSchema, data);
1409
+ }
1410
+ export function parseExtractedGuideSuggestion(data) {
1411
+ return v.parse(ExtractedGuideSuggestionSchema, data);
1412
+ }
1413
+ export function parseExtractedSkillSuggestion(data) {
1414
+ return v.parse(ExtractedSkillSuggestionSchema, data);
1415
+ }
1416
+ export function parseExtractedFeedbackBatch(data) {
1417
+ return v.parse(ExtractedFeedbackBatchSchema, data);
1418
+ }
1419
+ export function parseActionEntry(data) {
1420
+ return v.parse(ActionEntrySchema, data);
1421
+ }
1422
+ //# sourceMappingURL=schemas.js.map