@cleocode/contracts 2026.4.69 → 2026.4.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/acceptance-gate.test.d.ts +14 -0
- package/dist/__tests__/acceptance-gate.test.d.ts.map +1 -0
- package/dist/__tests__/acceptance-gate.test.js +642 -0
- package/dist/__tests__/acceptance-gate.test.js.map +1 -0
- package/dist/__tests__/attachment.test.d.ts +17 -0
- package/dist/__tests__/attachment.test.d.ts.map +1 -0
- package/dist/__tests__/attachment.test.js +505 -0
- package/dist/__tests__/attachment.test.js.map +1 -0
- package/dist/__tests__/evidence-record.test.d.ts +14 -0
- package/dist/__tests__/evidence-record.test.d.ts.map +1 -0
- package/dist/__tests__/evidence-record.test.js +362 -0
- package/dist/__tests__/evidence-record.test.js.map +1 -0
- package/dist/__tests__/invariants.test.d.ts +19 -0
- package/dist/__tests__/invariants.test.d.ts.map +1 -0
- package/dist/__tests__/invariants.test.js +351 -0
- package/dist/__tests__/invariants.test.js.map +1 -0
- package/dist/acceptance-gate-schema.d.ts +701 -0
- package/dist/acceptance-gate-schema.d.ts.map +1 -0
- package/dist/acceptance-gate-schema.js +303 -0
- package/dist/acceptance-gate-schema.js.map +1 -0
- package/dist/acceptance-gate.d.ts +350 -0
- package/dist/acceptance-gate.d.ts.map +1 -0
- package/dist/acceptance-gate.js +22 -0
- package/dist/acceptance-gate.js.map +1 -0
- package/dist/attachment-schema.d.ts +243 -0
- package/dist/attachment-schema.d.ts.map +1 -0
- package/dist/attachment-schema.js +200 -0
- package/dist/attachment-schema.js.map +1 -0
- package/dist/attachment.d.ts +224 -0
- package/dist/attachment.d.ts.map +1 -0
- package/dist/attachment.js +21 -0
- package/dist/attachment.js.map +1 -0
- package/dist/evidence-record-schema.d.ts +202 -0
- package/dist/evidence-record-schema.d.ts.map +1 -0
- package/dist/evidence-record-schema.js +130 -0
- package/dist/evidence-record-schema.js.map +1 -0
- package/dist/evidence-record.d.ts +167 -0
- package/dist/evidence-record.d.ts.map +1 -0
- package/dist/evidence-record.js +21 -0
- package/dist/evidence-record.js.map +1 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/task-evidence.d.ts +251 -0
- package/dist/task-evidence.d.ts.map +1 -0
- package/dist/task-evidence.js +89 -0
- package/dist/task-evidence.js.map +1 -0
- package/dist/task.d.ts +23 -4
- package/dist/task.d.ts.map +1 -1
- package/package.json +8 -2
- package/schemas/acceptance-gate.schema.json +9 -0
- package/schemas/attachment.schema.json +9 -0
- package/schemas/gate-result-details.schema.json +9 -0
- package/schemas/gate-result.schema.json +9 -0
- package/schemas/manifest-v1.json +176 -0
- package/schemas/task-evidence.schema.json +9 -0
- package/schemas/task.schema.json +9 -0
- package/src/__tests__/acceptance-gate.test.ts +736 -0
- package/src/__tests__/attachment.test.ts +601 -0
- package/src/__tests__/evidence-record.test.ts +412 -0
- package/src/__tests__/invariants.test.ts +445 -0
- package/src/acceptance-gate-schema.ts +347 -0
- package/src/acceptance-gate.ts +370 -0
- package/src/attachment-schema.ts +223 -0
- package/src/attachment.ts +245 -0
- package/src/evidence-record-schema.ts +158 -0
- package/src/evidence-record.ts +182 -0
- package/src/index.ts +114 -1
- package/src/task-evidence.ts +239 -0
- package/src/task.ts +24 -4
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acceptance gate discriminated union and result types.
|
|
3
|
+
*
|
|
4
|
+
* A machine-verifiable acceptance gate. Gates coexist with free-text criteria
|
|
5
|
+
* in `Task.acceptance`; the runtime executes only gates and records results in
|
|
6
|
+
* `task.verification.gateResults` and in the `lifecycle_gate_results` DB table.
|
|
7
|
+
*
|
|
8
|
+
* Six gate kinds are supported:
|
|
9
|
+
* - `test` — run a command and assert exit code / test-count
|
|
10
|
+
* - `file` — assert properties of a file on disk
|
|
11
|
+
* - `command` — run any CLI command and assert exit code / stdout
|
|
12
|
+
* - `lint` — run a static-analysis tool and require a clean result
|
|
13
|
+
* - `http` — hit a URL and assert status + optional body
|
|
14
|
+
* - `manual` — explicit escape hatch requiring human/agent verdict
|
|
15
|
+
*
|
|
16
|
+
* @epic T760
|
|
17
|
+
* @task T763
|
|
18
|
+
* @task T779
|
|
19
|
+
* @see {@link https://github.com/kryptobaseddev/cleo} T760 RCASD hardening
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// ─── Base ────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/** Fields every gate variant carries. */
|
|
25
|
+
export interface GateBase {
|
|
26
|
+
/**
|
|
27
|
+
* Optional REQ-ID (GSD-style: `TIMER-03`, `A11Y-04`). When present the
|
|
28
|
+
* gate is addressable via `cleo req show <taskId> --id TIMER-03` and its
|
|
29
|
+
* result is indexed in `lifecycle_gate_results.gate_name`.
|
|
30
|
+
*/
|
|
31
|
+
req?: string;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Free-text description of what this gate is checking. Shown in
|
|
35
|
+
* `cleo show` output and in failure messages. Required so agents and
|
|
36
|
+
* humans can read the gate without executing it.
|
|
37
|
+
*/
|
|
38
|
+
description: string;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* When `true` a failure of this gate does not block completion — it is
|
|
42
|
+
* recorded as `result: 'warn'` in `lifecycle_gate_results`.
|
|
43
|
+
*
|
|
44
|
+
* @defaultValue false
|
|
45
|
+
*/
|
|
46
|
+
advisory?: boolean;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Gate timeout in milliseconds.
|
|
50
|
+
*
|
|
51
|
+
* @defaultValue 120_000
|
|
52
|
+
*/
|
|
53
|
+
timeoutMs?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Variants ────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Run a command; pass when exit code is 0 and when at least `minCount`
|
|
60
|
+
* tests have run. Designed for test suites:
|
|
61
|
+
* `{ kind: 'test', command: 'pnpm test', expect: 'pass' }`.
|
|
62
|
+
*/
|
|
63
|
+
export interface TestGate extends GateBase {
|
|
64
|
+
kind: 'test';
|
|
65
|
+
/** Shell command. Executed via `child_process.spawn`, no `shell: true`. */
|
|
66
|
+
command: string;
|
|
67
|
+
/** Arguments split explicitly (avoids shell-injection). */
|
|
68
|
+
args?: string[];
|
|
69
|
+
/**
|
|
70
|
+
* - `"pass"`: exit code 0 AND stdout contains no `FAIL|failing|Error:` pattern.
|
|
71
|
+
* - `"exit0"`: exit code 0 only (permissive mode).
|
|
72
|
+
*/
|
|
73
|
+
expect: 'pass' | 'exit0';
|
|
74
|
+
/** Minimum number of tests that must have run. */
|
|
75
|
+
minCount?: number;
|
|
76
|
+
/** Working directory relative to project root. Default `.`. */
|
|
77
|
+
cwd?: string;
|
|
78
|
+
/** Environment variable overrides. Keys must match `/^[A-Z_][A-Z0-9_]*$/`. */
|
|
79
|
+
env?: Record<string, string>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Assert properties of a file on disk. Multiple assertions are AND-ed:
|
|
84
|
+
* all must hold for the gate to pass.
|
|
85
|
+
*
|
|
86
|
+
* Either `path` or `attachmentSha256` must be provided (not both).
|
|
87
|
+
* When `attachmentSha256` is set, the gate runner resolves the on-disk path
|
|
88
|
+
* via `AttachmentStore.get(sha256)` before running assertions.
|
|
89
|
+
*/
|
|
90
|
+
export interface FileGate extends GateBase {
|
|
91
|
+
kind: 'file';
|
|
92
|
+
/**
|
|
93
|
+
* Absolute or project-root-relative path. Globs are not supported here
|
|
94
|
+
* — use one gate per path.
|
|
95
|
+
*
|
|
96
|
+
* Exactly one of `path` or `attachmentSha256` must be set.
|
|
97
|
+
*/
|
|
98
|
+
path?: string;
|
|
99
|
+
/**
|
|
100
|
+
* SHA-256 hex digest of an attachment stored in the CLEO attachment store.
|
|
101
|
+
*
|
|
102
|
+
* When set, the gate runner resolves the attachment's on-disk blob path
|
|
103
|
+
* via `AttachmentStore.get(sha256)` and uses that path for all assertions.
|
|
104
|
+
* Mutually exclusive with `path`.
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```json
|
|
108
|
+
* { "kind": "file", "attachmentSha256": "a1b2c3...", "assertions": [{ "type": "nonEmpty" }] }
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
attachmentSha256?: string;
|
|
112
|
+
assertions: FileAssertion[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A single assertion about a file on disk.
|
|
117
|
+
*
|
|
118
|
+
* All assertion variants in a `FileGate` must pass for the gate to pass.
|
|
119
|
+
*/
|
|
120
|
+
export type FileAssertion =
|
|
121
|
+
| { type: 'exists' }
|
|
122
|
+
| { type: 'absent' }
|
|
123
|
+
| { type: 'nonEmpty' }
|
|
124
|
+
| { type: 'maxBytes'; value: number }
|
|
125
|
+
| { type: 'minBytes'; value: number }
|
|
126
|
+
| { type: 'contains'; value: string }
|
|
127
|
+
| {
|
|
128
|
+
type: 'matches';
|
|
129
|
+
/** Serialized `RegExp` source string. */
|
|
130
|
+
regex: string;
|
|
131
|
+
/** Optional `RegExp` flags, e.g. `"gim"`. */
|
|
132
|
+
flags?: string;
|
|
133
|
+
}
|
|
134
|
+
| { type: 'sha256'; value: string };
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Run any CLI command and assert exit code and optional stdout match.
|
|
138
|
+
* Escape hatch for anything `TestGate`/`LintGate`/`HttpGate` do not cover.
|
|
139
|
+
*/
|
|
140
|
+
export interface CommandGate extends GateBase {
|
|
141
|
+
kind: 'command';
|
|
142
|
+
cmd: string;
|
|
143
|
+
args?: string[];
|
|
144
|
+
/**
|
|
145
|
+
* Expected exit code.
|
|
146
|
+
*
|
|
147
|
+
* @defaultValue 0
|
|
148
|
+
*/
|
|
149
|
+
exitCode?: number;
|
|
150
|
+
/** stdout must match this regex source. */
|
|
151
|
+
stdoutMatches?: string;
|
|
152
|
+
/** stderr must match this regex source. */
|
|
153
|
+
stderrMatches?: string;
|
|
154
|
+
/** Working directory relative to project root. */
|
|
155
|
+
cwd?: string;
|
|
156
|
+
/** Environment variable overrides. */
|
|
157
|
+
env?: Record<string, string>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Run a static-analysis tool and require a clean result.
|
|
162
|
+
* Wraps common cases with known exit codes so agents do not re-invent them.
|
|
163
|
+
*/
|
|
164
|
+
export interface LintGate extends GateBase {
|
|
165
|
+
kind: 'lint';
|
|
166
|
+
/** Linting tool to invoke. */
|
|
167
|
+
tool: 'biome' | 'eslint' | 'tsc' | 'prettier' | 'rustc' | 'clippy';
|
|
168
|
+
/**
|
|
169
|
+
* Tool arguments. Defaults: biome→`['check', '.']`, eslint→`['.']`,
|
|
170
|
+
* tsc→`['--noEmit']`.
|
|
171
|
+
*/
|
|
172
|
+
args?: string[];
|
|
173
|
+
/**
|
|
174
|
+
* - `"clean"`: zero findings.
|
|
175
|
+
* - `"noErrors"`: tolerates warnings but not errors.
|
|
176
|
+
*/
|
|
177
|
+
expect: 'clean' | 'noErrors';
|
|
178
|
+
/** Working directory relative to project root. */
|
|
179
|
+
cwd?: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Hit a URL and assert HTTP status and optional body match. For tasks that
|
|
184
|
+
* ship a webapp or API. The runner starts a server only if `startCommand`
|
|
185
|
+
* is set and tears it down after the probe.
|
|
186
|
+
*/
|
|
187
|
+
export interface HttpGate extends GateBase {
|
|
188
|
+
kind: 'http';
|
|
189
|
+
url: string;
|
|
190
|
+
/** HTTP method for the probe request. Default `"GET"`. */
|
|
191
|
+
method?: 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE';
|
|
192
|
+
/** Expected HTTP status code. */
|
|
193
|
+
status: number;
|
|
194
|
+
/** Response body must match this regex source. */
|
|
195
|
+
bodyMatches?: string;
|
|
196
|
+
/** Request headers to include. */
|
|
197
|
+
headers?: Record<string, string>;
|
|
198
|
+
/** Pre-probe command to start a server. Runs in background; killed after probe. */
|
|
199
|
+
startCommand?: string;
|
|
200
|
+
/**
|
|
201
|
+
* Milliseconds to wait after starting before probing.
|
|
202
|
+
*
|
|
203
|
+
* @defaultValue 2000
|
|
204
|
+
*/
|
|
205
|
+
startupDelayMs?: number;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Explicit escape hatch: a gate the runtime CANNOT verify automatically.
|
|
210
|
+
* Requires a human or a different agent to record a verdict before
|
|
211
|
+
* `cleo complete` will accept it.
|
|
212
|
+
*
|
|
213
|
+
* This is the ONLY gate variant that preserves today's free-text behaviour.
|
|
214
|
+
* Use for subjective criteria: "copy reads well", "visual design matches mockup".
|
|
215
|
+
*/
|
|
216
|
+
export interface ManualGate extends GateBase {
|
|
217
|
+
kind: 'manual';
|
|
218
|
+
/** Question or prompt shown to the human or agent performing verification. */
|
|
219
|
+
prompt: string;
|
|
220
|
+
/**
|
|
221
|
+
* Valid verdicts the reviewer may record.
|
|
222
|
+
*
|
|
223
|
+
* @defaultValue ['pass', 'fail']
|
|
224
|
+
*/
|
|
225
|
+
verdicts?: ('pass' | 'fail' | 'warn')[];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── Discriminated union ──────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A machine-verifiable acceptance gate attached to a task.
|
|
232
|
+
*
|
|
233
|
+
* All variants share the {@link GateBase} fields (`req`, `description`,
|
|
234
|
+
* `advisory`, `timeoutMs`). The `kind` discriminant selects the variant.
|
|
235
|
+
*
|
|
236
|
+
* @example
|
|
237
|
+
* ```ts
|
|
238
|
+
* const gate: AcceptanceGate = {
|
|
239
|
+
* kind: 'test',
|
|
240
|
+
* command: 'pnpm test',
|
|
241
|
+
* expect: 'pass',
|
|
242
|
+
* minCount: 3,
|
|
243
|
+
* description: '≥3 tests must pass',
|
|
244
|
+
* };
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
export type AcceptanceGate = TestGate | FileGate | CommandGate | LintGate | HttpGate | ManualGate;
|
|
248
|
+
|
|
249
|
+
/** All valid `kind` discriminants for `AcceptanceGate`. */
|
|
250
|
+
export type AcceptanceGateKind = AcceptanceGate['kind'];
|
|
251
|
+
|
|
252
|
+
// ─── Result details (typed per-kind) ─────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Kind-specific detail payload for {@link AcceptanceGateResult}.
|
|
256
|
+
*
|
|
257
|
+
* Each variant is keyed by `kind` — the same discriminant used on
|
|
258
|
+
* {@link AcceptanceGate} — so callers can narrow `details` with a simple
|
|
259
|
+
* `if (result.details?.kind === 'test') { ... }` guard.
|
|
260
|
+
*
|
|
261
|
+
* @epic T760
|
|
262
|
+
* @task T802
|
|
263
|
+
*/
|
|
264
|
+
export type GateResultDetails =
|
|
265
|
+
| {
|
|
266
|
+
kind: 'test';
|
|
267
|
+
/** Exit code of the test process. */
|
|
268
|
+
exitCode: number;
|
|
269
|
+
/** Captured standard output (may be truncated). */
|
|
270
|
+
stdout: string;
|
|
271
|
+
/** Captured standard error (may be truncated). */
|
|
272
|
+
stderr: string;
|
|
273
|
+
/** Wall-clock duration in milliseconds. */
|
|
274
|
+
duration: number;
|
|
275
|
+
}
|
|
276
|
+
| {
|
|
277
|
+
kind: 'file';
|
|
278
|
+
/** The file path that was evaluated. */
|
|
279
|
+
path: string;
|
|
280
|
+
/** Assertions that passed. */
|
|
281
|
+
passedAssertions: string[];
|
|
282
|
+
/** Assertions that failed (empty on pass). */
|
|
283
|
+
failedAssertions: string[];
|
|
284
|
+
}
|
|
285
|
+
| {
|
|
286
|
+
kind: 'command';
|
|
287
|
+
/** The command that was run. */
|
|
288
|
+
cmd: string;
|
|
289
|
+
/** Exit code of the command process. */
|
|
290
|
+
exitCode: number;
|
|
291
|
+
/** Captured standard output (may be truncated). */
|
|
292
|
+
stdout: string;
|
|
293
|
+
}
|
|
294
|
+
| {
|
|
295
|
+
kind: 'lint';
|
|
296
|
+
/** Linting tool name (e.g. `"biome"`, `"tsc"`). */
|
|
297
|
+
tool: string;
|
|
298
|
+
/** Number of diagnostic warnings emitted. */
|
|
299
|
+
warnings: number;
|
|
300
|
+
/** Number of diagnostic errors emitted. */
|
|
301
|
+
errors: number;
|
|
302
|
+
}
|
|
303
|
+
| {
|
|
304
|
+
kind: 'http';
|
|
305
|
+
/** The URL that was probed. */
|
|
306
|
+
url: string;
|
|
307
|
+
/** HTTP status code returned by the server. */
|
|
308
|
+
status: number;
|
|
309
|
+
/** Response body (may be truncated). */
|
|
310
|
+
body: string;
|
|
311
|
+
}
|
|
312
|
+
| {
|
|
313
|
+
kind: 'manual';
|
|
314
|
+
/** The prompt shown to the reviewer. */
|
|
315
|
+
prompt: string;
|
|
316
|
+
/** Whether the reviewer accepted (`true`) or rejected (`false`) the gate. */
|
|
317
|
+
accepted: boolean;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// ─── Result ──────────────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Result of running one acceptance gate.
|
|
324
|
+
*
|
|
325
|
+
* Persisted to `lifecycle_gate_results` and summarised in
|
|
326
|
+
* `task.verification.gateResults`.
|
|
327
|
+
*
|
|
328
|
+
* NOTE: Named `AcceptanceGateResult` (not `GateResult`) to avoid collision
|
|
329
|
+
* with the existing {@link GateResult} from `./warp-chain.js` which describes
|
|
330
|
+
* WarpChain LOOM-gate evaluation outcomes.
|
|
331
|
+
*/
|
|
332
|
+
export interface AcceptanceGateResult {
|
|
333
|
+
/** Zero-based index in the task's acceptance array. */
|
|
334
|
+
index: number;
|
|
335
|
+
/** REQ-ID if the gate had one, else `undefined`. */
|
|
336
|
+
req?: string;
|
|
337
|
+
/** Gate variant. */
|
|
338
|
+
kind: AcceptanceGateKind;
|
|
339
|
+
/** Outcome of the gate execution. */
|
|
340
|
+
result: 'pass' | 'fail' | 'warn' | 'skipped' | 'error';
|
|
341
|
+
/** Wall-clock duration of the gate execution in milliseconds. */
|
|
342
|
+
durationMs: number;
|
|
343
|
+
/**
|
|
344
|
+
* Typed kind-specific detail payload.
|
|
345
|
+
*
|
|
346
|
+
* Replaces the untyped `evidence?: string` pattern. The `kind` discriminant
|
|
347
|
+
* matches the gate's `kind` so callers can narrow safely:
|
|
348
|
+
*
|
|
349
|
+
* ```ts
|
|
350
|
+
* if (r.details?.kind === 'test') {
|
|
351
|
+
* console.log(r.details.exitCode, r.details.stdout);
|
|
352
|
+
* }
|
|
353
|
+
* ```
|
|
354
|
+
*/
|
|
355
|
+
details?: GateResultDetails;
|
|
356
|
+
/**
|
|
357
|
+
* Short plain-text evidence snippet shown in failure messages.
|
|
358
|
+
*
|
|
359
|
+
* Kept for backward compatibility with existing persisted records and
|
|
360
|
+
* display code that reads this field as a truncated summary.
|
|
361
|
+
* New code should prefer {@link details}.
|
|
362
|
+
*/
|
|
363
|
+
evidence?: string;
|
|
364
|
+
/** Error message when `result` is `"error"` (the gate runner itself crashed). */
|
|
365
|
+
errorMessage?: string;
|
|
366
|
+
/** ISO 8601 timestamp at which the gate ran. */
|
|
367
|
+
checkedAt: string;
|
|
368
|
+
/** Agent identifier or `"human"` that ran or attested the gate. */
|
|
369
|
+
checkedBy: string;
|
|
370
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod validation schemas for {@link Attachment} and related types.
|
|
3
|
+
*
|
|
4
|
+
* Kept in a separate file from the plain TypeScript types so that consumers
|
|
5
|
+
* that do not need runtime validation (e.g. type-only imports) pay zero cost.
|
|
6
|
+
*
|
|
7
|
+
* All schemas are named `*Schema` and their inferred types are compatible with
|
|
8
|
+
* the canonical TypeScript interfaces in `./attachment.ts`.
|
|
9
|
+
*
|
|
10
|
+
* Uses `z.discriminatedUnion('kind', [...])` for O(1) variant lookup on the
|
|
11
|
+
* `kind` discriminant.
|
|
12
|
+
*
|
|
13
|
+
* @epic T760
|
|
14
|
+
* @task T795
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
|
|
19
|
+
// ─── Shared field schemas ──────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Common optional fields shared by all attachment variants.
|
|
23
|
+
*
|
|
24
|
+
* Not exported as a standalone validator — always composed into full variant
|
|
25
|
+
* schemas via `.extend()`.
|
|
26
|
+
*/
|
|
27
|
+
const attachmentCommonSchema = z.object({
|
|
28
|
+
/** Free-text description of what this attachment contains. */
|
|
29
|
+
description: z.string().optional(),
|
|
30
|
+
/** Labels for filtering (e.g., `["rfc", "spec"]`). */
|
|
31
|
+
labels: z.array(z.string()).optional(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// ─── Variant schemas ───────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Zod schema for {@link LocalFileAttachment}.
|
|
38
|
+
*
|
|
39
|
+
* Validates that `path` is non-empty, `sha256` is a 64-character hex string,
|
|
40
|
+
* and `size` is a non-negative integer.
|
|
41
|
+
*/
|
|
42
|
+
export const localFileAttachmentSchema = attachmentCommonSchema.extend({
|
|
43
|
+
kind: z.literal('local-file'),
|
|
44
|
+
/** Path to the file (absolute or project-root-relative, forward-slashes only). */
|
|
45
|
+
path: z.string().min(1),
|
|
46
|
+
/** SHA-256 hex digest (64 hex characters). */
|
|
47
|
+
sha256: z.string().length(64),
|
|
48
|
+
/** IANA MIME type. */
|
|
49
|
+
mime: z.string().min(1),
|
|
50
|
+
/** File size in bytes (non-negative integer). */
|
|
51
|
+
size: z.number().int().nonnegative(),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Zod schema for {@link UrlAttachment}.
|
|
56
|
+
*
|
|
57
|
+
* Validates that `url` is a well-formed absolute URL. The optional `cachedSha256`
|
|
58
|
+
* is validated as a 64-character hex string when present.
|
|
59
|
+
*/
|
|
60
|
+
export const urlAttachmentSchema = attachmentCommonSchema.extend({
|
|
61
|
+
kind: z.literal('url'),
|
|
62
|
+
/** The remote URL — must be a valid absolute URL. */
|
|
63
|
+
url: z.string().url(),
|
|
64
|
+
/** SHA-256 of the cached body (64 hex characters). */
|
|
65
|
+
cachedSha256: z.string().length(64).optional(),
|
|
66
|
+
/** ISO 8601 timestamp when the body was cached. */
|
|
67
|
+
cachedAt: z.string().datetime().optional(),
|
|
68
|
+
/** IANA MIME type detected from the Content-Type header. */
|
|
69
|
+
mime: z.string().optional(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Zod schema for {@link BlobAttachment}.
|
|
74
|
+
*
|
|
75
|
+
* Validates that `sha256` is 64 hex characters, `storageKey` is non-empty,
|
|
76
|
+
* and `size` is a non-negative integer.
|
|
77
|
+
*/
|
|
78
|
+
export const blobAttachmentSchema = attachmentCommonSchema.extend({
|
|
79
|
+
kind: z.literal('blob'),
|
|
80
|
+
/** SHA-256 hex digest of the uncompressed content (64 hex characters). */
|
|
81
|
+
sha256: z.string().length(64),
|
|
82
|
+
/** Storage key inside `.cleo/attachments/sha256/`. */
|
|
83
|
+
storageKey: z.string().min(1),
|
|
84
|
+
/** IANA MIME type. */
|
|
85
|
+
mime: z.string().min(1),
|
|
86
|
+
/** Size of the uncompressed content in bytes. */
|
|
87
|
+
size: z.number().int().nonnegative(),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Zod schema for {@link LlmsTxtAttachment}.
|
|
92
|
+
*
|
|
93
|
+
* Validates that `content` is non-empty, `sha256` is 64 hex characters, and
|
|
94
|
+
* `source` is one of the two allowed literal values.
|
|
95
|
+
*/
|
|
96
|
+
export const llmsTxtAttachmentSchema = attachmentCommonSchema.extend({
|
|
97
|
+
kind: z.literal('llms-txt'),
|
|
98
|
+
/** Where the llms.txt content originated. */
|
|
99
|
+
source: z.enum(['url', 'generated']),
|
|
100
|
+
/** Full markdown content of the llms.txt (stored inline). */
|
|
101
|
+
content: z.string().min(1),
|
|
102
|
+
/** SHA-256 hex digest of `content` (64 hex characters). */
|
|
103
|
+
sha256: z.string().length(64),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Zod schema for {@link LlmtxtDocAttachment}.
|
|
108
|
+
*
|
|
109
|
+
* Validates that `slug` is non-empty and `backend` is one of the two allowed
|
|
110
|
+
* literal values. `pinnedVersion` is an opaque string when present.
|
|
111
|
+
*/
|
|
112
|
+
export const llmtxtDocAttachmentSchema = attachmentCommonSchema.extend({
|
|
113
|
+
kind: z.literal('llmtxt-doc'),
|
|
114
|
+
/** Document slug from the llmtxt backend. */
|
|
115
|
+
slug: z.string().min(1),
|
|
116
|
+
/** Which backend owns the document. */
|
|
117
|
+
backend: z.enum(['local', 'remote']),
|
|
118
|
+
/**
|
|
119
|
+
* Version string pinned at attach time.
|
|
120
|
+
*
|
|
121
|
+
* Opaque: integer version for local backends, API-assigned string for remote.
|
|
122
|
+
*/
|
|
123
|
+
pinnedVersion: z.string().optional(),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ─── Discriminated union ──────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Zod discriminated-union schema for {@link Attachment}.
|
|
130
|
+
*
|
|
131
|
+
* Uses `z.discriminatedUnion('kind', [...])` for O(1) variant lookup.
|
|
132
|
+
* Parse with `attachmentSchema.parse(input)` or
|
|
133
|
+
* `attachmentSchema.safeParse(input)`.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```ts
|
|
137
|
+
* const att = attachmentSchema.parse({
|
|
138
|
+
* kind: 'local-file',
|
|
139
|
+
* path: 'docs/rfc-003.md',
|
|
140
|
+
* sha256: 'a'.repeat(64),
|
|
141
|
+
* mime: 'text/markdown',
|
|
142
|
+
* size: 8192,
|
|
143
|
+
* description: 'RFC draft v3',
|
|
144
|
+
* });
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
export const attachmentSchema = z.discriminatedUnion('kind', [
|
|
148
|
+
localFileAttachmentSchema,
|
|
149
|
+
urlAttachmentSchema,
|
|
150
|
+
blobAttachmentSchema,
|
|
151
|
+
llmsTxtAttachmentSchema,
|
|
152
|
+
llmtxtDocAttachmentSchema,
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
/** Inferred TypeScript type from {@link attachmentSchema}. */
|
|
156
|
+
export type AttachmentSchemaInput = z.input<typeof attachmentSchema>;
|
|
157
|
+
|
|
158
|
+
// ─── AttachmentMetadata schema ────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Zod schema for {@link AttachmentMetadata}.
|
|
162
|
+
*
|
|
163
|
+
* Validates the persisted registry row. The embedded `attachment` field is
|
|
164
|
+
* validated recursively through the discriminated union.
|
|
165
|
+
*/
|
|
166
|
+
export const attachmentMetadataSchema = z.object({
|
|
167
|
+
/**
|
|
168
|
+
* Unique attachment ID.
|
|
169
|
+
*
|
|
170
|
+
* Pattern: `att_<base62>` or UUID fallback. Must be non-empty.
|
|
171
|
+
*/
|
|
172
|
+
id: z.string().min(1),
|
|
173
|
+
/**
|
|
174
|
+
* Content hash — SHA-256 hex (64 chars) for byte-backed kinds, empty string
|
|
175
|
+
* for kinds that delegate content storage externally.
|
|
176
|
+
*/
|
|
177
|
+
sha256: z.string(),
|
|
178
|
+
/** The full attachment value (validated through the discriminated union). */
|
|
179
|
+
attachment: attachmentSchema,
|
|
180
|
+
/** ISO 8601 creation timestamp. */
|
|
181
|
+
createdAt: z.string().datetime(),
|
|
182
|
+
/**
|
|
183
|
+
* Reference count — how many `AttachmentRef` rows point at this attachment.
|
|
184
|
+
*
|
|
185
|
+
* Must be a non-negative integer.
|
|
186
|
+
*/
|
|
187
|
+
refCount: z.number().int().nonnegative(),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
/** Inferred TypeScript type from {@link attachmentMetadataSchema}. */
|
|
191
|
+
export type AttachmentMetadataSchemaInput = z.input<typeof attachmentMetadataSchema>;
|
|
192
|
+
|
|
193
|
+
// ─── AttachmentRef schema ─────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Zod schema for {@link AttachmentRef}.
|
|
197
|
+
*
|
|
198
|
+
* Validates the junction row linking one attachment to one owner. `ownerType`
|
|
199
|
+
* is restricted to the six supported CLEO entity types.
|
|
200
|
+
*/
|
|
201
|
+
export const attachmentRefSchema = z.object({
|
|
202
|
+
/** ID of the attachment (→ `attachments.id`). Must be non-empty. */
|
|
203
|
+
attachmentId: z.string().min(1),
|
|
204
|
+
/**
|
|
205
|
+
* The domain entity type that owns this attachment.
|
|
206
|
+
*
|
|
207
|
+
* Restricted to the six supported CLEO entity types.
|
|
208
|
+
*/
|
|
209
|
+
ownerType: z.enum(['task', 'observation', 'session', 'decision', 'learning', 'pattern']),
|
|
210
|
+
/** ID of the owning entity. Must be non-empty. */
|
|
211
|
+
ownerId: z.string().min(1),
|
|
212
|
+
/** ISO 8601 timestamp when the ref was created. */
|
|
213
|
+
attachedAt: z.string().datetime(),
|
|
214
|
+
/**
|
|
215
|
+
* Agent identity (or `"human"`) that created the ref.
|
|
216
|
+
*
|
|
217
|
+
* Optional; populated from the active session credential when available.
|
|
218
|
+
*/
|
|
219
|
+
attachedBy: z.string().optional(),
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
/** Inferred TypeScript type from {@link attachmentRefSchema}. */
|
|
223
|
+
export type AttachmentRefSchemaInput = z.input<typeof attachmentRefSchema>;
|