@picoai/tickets 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.tickets/spec/AGENTS_EXAMPLE.md +16 -0
- package/.tickets/spec/TICKETS.md +469 -0
- package/.tickets/spec/version/20260205-tickets-spec.md +34 -0
- package/.tickets/spec/version/PROPOSED-tickets-spec.md +15 -0
- package/LICENSE +201 -0
- package/README.md +228 -0
- package/bin/tickets.js +5 -0
- package/package.json +39 -0
- package/src/cli.js +1488 -0
- package/src/lib/constants.js +7 -0
- package/src/lib/listing.js +85 -0
- package/src/lib/repair.js +338 -0
- package/src/lib/util.js +146 -0
- package/src/lib/validation.js +482 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ASSIGNMENT_MODE_VALUES,
|
|
6
|
+
PRIORITY_VALUES,
|
|
7
|
+
STATUS_VALUES,
|
|
8
|
+
} from "./constants.js";
|
|
9
|
+
import {
|
|
10
|
+
isUuidv7,
|
|
11
|
+
listTicketDirs,
|
|
12
|
+
loadTicket,
|
|
13
|
+
parseIso,
|
|
14
|
+
readJsonl,
|
|
15
|
+
repoRoot,
|
|
16
|
+
} from "./util.js";
|
|
17
|
+
|
|
18
|
+
function parseVersion(value) {
|
|
19
|
+
if (typeof value === "number" && Number.isInteger(value)) {
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
if (typeof value === "string" && /^\d+$/.test(value.trim())) {
|
|
23
|
+
return Number.parseInt(value.trim(), 10);
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function collectTicketPaths(target) {
|
|
29
|
+
if (target) {
|
|
30
|
+
const resolved = path.resolve(repoRoot(), target);
|
|
31
|
+
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
|
32
|
+
return [path.join(resolved, "ticket.md")];
|
|
33
|
+
}
|
|
34
|
+
return [resolved];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return listTicketDirs()
|
|
38
|
+
.map((ticketDir) => path.join(ticketDir, "ticket.md"))
|
|
39
|
+
.filter((ticketPath) => fs.existsSync(ticketPath));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function validateTicket(ticketPath, allFields = false) {
|
|
43
|
+
const issues = [];
|
|
44
|
+
let frontMatter;
|
|
45
|
+
let body;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
[frontMatter, body] = loadTicket(ticketPath);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
issues.push({
|
|
51
|
+
severity: "error",
|
|
52
|
+
code: "TICKET_FRONT_MATTER_INVALID",
|
|
53
|
+
message: String(error.message ?? error),
|
|
54
|
+
ticket_path: ticketPath,
|
|
55
|
+
});
|
|
56
|
+
return [issues, {}, ""];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const requiredFields = ["id", "title", "status", "created_at"];
|
|
60
|
+
for (const field of requiredFields) {
|
|
61
|
+
if (!(field in frontMatter)) {
|
|
62
|
+
issues.push({
|
|
63
|
+
severity: "error",
|
|
64
|
+
code: `MISSING_${field.toUpperCase()}`,
|
|
65
|
+
message: `Missing ${field}`,
|
|
66
|
+
ticket_path: ticketPath,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!("version" in frontMatter)) {
|
|
72
|
+
issues.push({
|
|
73
|
+
severity: "warning",
|
|
74
|
+
code: "VERSION_MISSING",
|
|
75
|
+
message: "Missing version (assume 1 for legacy tickets)",
|
|
76
|
+
ticket_path: ticketPath,
|
|
77
|
+
});
|
|
78
|
+
} else {
|
|
79
|
+
const version = parseVersion(frontMatter.version);
|
|
80
|
+
if (version === null || version <= 0) {
|
|
81
|
+
issues.push({
|
|
82
|
+
severity: "error",
|
|
83
|
+
code: "VERSION_INVALID",
|
|
84
|
+
message: "version must be a positive integer",
|
|
85
|
+
ticket_path: ticketPath,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!("version_url" in frontMatter)) {
|
|
90
|
+
issues.push({
|
|
91
|
+
severity: "error",
|
|
92
|
+
code: "VERSION_URL_MISSING",
|
|
93
|
+
message: "version_url required when version is present",
|
|
94
|
+
ticket_path: ticketPath,
|
|
95
|
+
});
|
|
96
|
+
} else if (typeof frontMatter.version_url !== "string" || !frontMatter.version_url.trim()) {
|
|
97
|
+
issues.push({
|
|
98
|
+
severity: "error",
|
|
99
|
+
code: "VERSION_URL_INVALID",
|
|
100
|
+
message: "version_url must be a non-empty string",
|
|
101
|
+
ticket_path: ticketPath,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if ("version_url" in frontMatter && !("version" in frontMatter)) {
|
|
107
|
+
issues.push({
|
|
108
|
+
severity: "warning",
|
|
109
|
+
code: "VERSION_URL_WITHOUT_VERSION",
|
|
110
|
+
message: "version_url present without version",
|
|
111
|
+
ticket_path: ticketPath,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if ("id" in frontMatter && (!isUuidv7(frontMatter.id) || typeof frontMatter.id !== "string")) {
|
|
116
|
+
issues.push({
|
|
117
|
+
severity: "error",
|
|
118
|
+
code: "ID_NOT_UUIDV7",
|
|
119
|
+
message: "id must be UUIDv7",
|
|
120
|
+
ticket_path: ticketPath,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if ("created_at" in frontMatter) {
|
|
125
|
+
if (typeof frontMatter.created_at !== "string" || !parseIso(frontMatter.created_at)) {
|
|
126
|
+
issues.push({
|
|
127
|
+
severity: "error",
|
|
128
|
+
code: "CREATED_AT_INVALID",
|
|
129
|
+
message: "created_at must be ISO8601 UTC",
|
|
130
|
+
ticket_path: ticketPath,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if ("status" in frontMatter && !STATUS_VALUES.includes(frontMatter.status)) {
|
|
136
|
+
issues.push({
|
|
137
|
+
severity: "error",
|
|
138
|
+
code: "STATUS_INVALID",
|
|
139
|
+
message: "status invalid",
|
|
140
|
+
ticket_path: ticketPath,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if ("assignment" in frontMatter) {
|
|
145
|
+
if (!frontMatter.assignment || typeof frontMatter.assignment !== "object" || Array.isArray(frontMatter.assignment)) {
|
|
146
|
+
issues.push({
|
|
147
|
+
severity: "error",
|
|
148
|
+
code: "ASSIGNMENT_INVALID",
|
|
149
|
+
message: "assignment must be mapping",
|
|
150
|
+
ticket_path: ticketPath,
|
|
151
|
+
});
|
|
152
|
+
} else {
|
|
153
|
+
const mode = frontMatter.assignment.mode;
|
|
154
|
+
if (mode && !ASSIGNMENT_MODE_VALUES.includes(mode)) {
|
|
155
|
+
issues.push({
|
|
156
|
+
severity: "error",
|
|
157
|
+
code: "ASSIGNMENT_MODE_INVALID",
|
|
158
|
+
message: "assignment.mode invalid",
|
|
159
|
+
ticket_path: ticketPath,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if ("custom" in frontMatter && (typeof frontMatter.custom !== "object" || !frontMatter.custom || Array.isArray(frontMatter.custom))) {
|
|
166
|
+
issues.push({
|
|
167
|
+
severity: "error",
|
|
168
|
+
code: "CUSTOM_INVALID",
|
|
169
|
+
message: "custom must be mapping",
|
|
170
|
+
ticket_path: ticketPath,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const relationshipKeys = ["dependencies", "blocks", "related"];
|
|
175
|
+
for (const relationshipKey of relationshipKeys) {
|
|
176
|
+
if (!(relationshipKey in frontMatter)) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const value = frontMatter[relationshipKey];
|
|
181
|
+
if (!Array.isArray(value)) {
|
|
182
|
+
issues.push({
|
|
183
|
+
severity: "error",
|
|
184
|
+
code: "RELATIONSHIP_TYPE_INVALID",
|
|
185
|
+
message: `${relationshipKey} must be list`,
|
|
186
|
+
ticket_path: ticketPath,
|
|
187
|
+
});
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (const relationship of value) {
|
|
192
|
+
if (typeof relationship !== "string" || !isUuidv7(relationship)) {
|
|
193
|
+
issues.push({
|
|
194
|
+
severity: "error",
|
|
195
|
+
code: "RELATIONSHIP_ID_INVALID",
|
|
196
|
+
message: `${relationshipKey} entries must be UUIDv7`,
|
|
197
|
+
ticket_path: ticketPath,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const forbidden of ["parent", "subtickets", "supersedes", "duplicate_of"]) {
|
|
204
|
+
if (forbidden in frontMatter) {
|
|
205
|
+
issues.push({
|
|
206
|
+
severity: "error",
|
|
207
|
+
code: "RELATIONSHIP_KEY_FORBIDDEN",
|
|
208
|
+
message: `${forbidden} not allowed in ticket.md`,
|
|
209
|
+
ticket_path: ticketPath,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if ("agent_limits" in frontMatter) {
|
|
215
|
+
if (!frontMatter.agent_limits || typeof frontMatter.agent_limits !== "object" || Array.isArray(frontMatter.agent_limits)) {
|
|
216
|
+
issues.push({
|
|
217
|
+
severity: "error",
|
|
218
|
+
code: "AGENT_LIMITS_INVALID",
|
|
219
|
+
message: "agent_limits must be mapping",
|
|
220
|
+
ticket_path: ticketPath,
|
|
221
|
+
});
|
|
222
|
+
} else {
|
|
223
|
+
for (const key of [
|
|
224
|
+
"iteration_timebox_minutes",
|
|
225
|
+
"max_iterations",
|
|
226
|
+
"max_tool_calls",
|
|
227
|
+
"checkpoint_every_minutes",
|
|
228
|
+
]) {
|
|
229
|
+
if (!(key in frontMatter.agent_limits)) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const value = frontMatter.agent_limits[key];
|
|
233
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
234
|
+
issues.push({
|
|
235
|
+
severity: "error",
|
|
236
|
+
code: "AGENT_LIMIT_VALUE_INVALID",
|
|
237
|
+
message: `${key} must be positive int`,
|
|
238
|
+
ticket_path: ticketPath,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (allFields) {
|
|
246
|
+
if ("priority" in frontMatter && !PRIORITY_VALUES.includes(frontMatter.priority)) {
|
|
247
|
+
issues.push({
|
|
248
|
+
severity: "error",
|
|
249
|
+
code: "PRIORITY_INVALID",
|
|
250
|
+
message: "priority must be low|medium|high|critical",
|
|
251
|
+
ticket_path: ticketPath,
|
|
252
|
+
optional: true,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if ("labels" in frontMatter) {
|
|
257
|
+
if (!Array.isArray(frontMatter.labels)) {
|
|
258
|
+
issues.push({
|
|
259
|
+
severity: "error",
|
|
260
|
+
code: "LABELS_NOT_LIST",
|
|
261
|
+
message: "labels must be list of strings",
|
|
262
|
+
ticket_path: ticketPath,
|
|
263
|
+
optional: true,
|
|
264
|
+
});
|
|
265
|
+
} else {
|
|
266
|
+
for (const label of frontMatter.labels) {
|
|
267
|
+
if (typeof label !== "string") {
|
|
268
|
+
issues.push({
|
|
269
|
+
severity: "error",
|
|
270
|
+
code: "LABEL_INVALID_ENTRY",
|
|
271
|
+
message: "labels entries must be strings",
|
|
272
|
+
ticket_path: ticketPath,
|
|
273
|
+
optional: true,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (
|
|
281
|
+
frontMatter.assignment &&
|
|
282
|
+
typeof frontMatter.assignment === "object" &&
|
|
283
|
+
!Array.isArray(frontMatter.assignment)
|
|
284
|
+
) {
|
|
285
|
+
const owner = frontMatter.assignment.owner;
|
|
286
|
+
if (owner !== undefined && owner !== null && typeof owner !== "string") {
|
|
287
|
+
issues.push({
|
|
288
|
+
severity: "error",
|
|
289
|
+
code: "ASSIGNMENT_OWNER_INVALID",
|
|
290
|
+
message: "assignment.owner must be string",
|
|
291
|
+
ticket_path: ticketPath,
|
|
292
|
+
optional: true,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if ("verification" in frontMatter) {
|
|
298
|
+
const verification = frontMatter.verification;
|
|
299
|
+
if (!verification || typeof verification !== "object" || Array.isArray(verification)) {
|
|
300
|
+
issues.push({
|
|
301
|
+
severity: "error",
|
|
302
|
+
code: "VERIFICATION_INVALID",
|
|
303
|
+
message: "verification must be mapping",
|
|
304
|
+
ticket_path: ticketPath,
|
|
305
|
+
optional: true,
|
|
306
|
+
});
|
|
307
|
+
} else {
|
|
308
|
+
const commands = verification.commands;
|
|
309
|
+
if (commands !== undefined && !Array.isArray(commands)) {
|
|
310
|
+
issues.push({
|
|
311
|
+
severity: "error",
|
|
312
|
+
code: "VERIFICATION_COMMANDS_INVALID",
|
|
313
|
+
message: "verification.commands must be list of strings",
|
|
314
|
+
ticket_path: ticketPath,
|
|
315
|
+
optional: true,
|
|
316
|
+
});
|
|
317
|
+
} else if (Array.isArray(commands)) {
|
|
318
|
+
for (const command of commands) {
|
|
319
|
+
if (typeof command !== "string") {
|
|
320
|
+
issues.push({
|
|
321
|
+
severity: "error",
|
|
322
|
+
code: "VERIFICATION_COMMAND_INVALID",
|
|
323
|
+
message: "verification.commands entries must be strings",
|
|
324
|
+
ticket_path: ticketPath,
|
|
325
|
+
optional: true,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
for (const heading of ["# Ticket", "## Description", "## Acceptance Criteria", "## Verification"]) {
|
|
335
|
+
if (!body.includes(heading)) {
|
|
336
|
+
issues.push({
|
|
337
|
+
severity: "error",
|
|
338
|
+
code: "MISSING_SECTION",
|
|
339
|
+
message: `Missing section ${heading}`,
|
|
340
|
+
ticket_path: ticketPath,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return [issues, frontMatter, body];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function validateRunLog(logPath, machineStrictDefault) {
|
|
349
|
+
const issues = [];
|
|
350
|
+
const filename = path.basename(logPath);
|
|
351
|
+
let expectedPrefix = null;
|
|
352
|
+
if (filename.includes("-")) {
|
|
353
|
+
expectedPrefix = filename.replace(/\.jsonl$/, "").split("-", 1)[0];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const entries = readJsonl(logPath);
|
|
357
|
+
let runStartedValue = null;
|
|
358
|
+
|
|
359
|
+
entries.forEach((entry, idx) => {
|
|
360
|
+
const loc = `${logPath}:${idx + 1}`;
|
|
361
|
+
const machineEntry =
|
|
362
|
+
machineStrictDefault || entry.written_by === "tickets" || entry.machine === true;
|
|
363
|
+
|
|
364
|
+
for (const required of ["ts", "run_started", "actor_type", "actor_id", "summary"]) {
|
|
365
|
+
if (!(required in entry)) {
|
|
366
|
+
issues.push({
|
|
367
|
+
severity: machineEntry ? "error" : "warning",
|
|
368
|
+
code: "LOG_FIELD_MISSING",
|
|
369
|
+
message: `${required} missing`,
|
|
370
|
+
log: loc,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (!("version" in entry)) {
|
|
376
|
+
issues.push({
|
|
377
|
+
severity: machineEntry ? "error" : "warning",
|
|
378
|
+
code: "LOG_VERSION_MISSING",
|
|
379
|
+
message: "version missing (assume 1 for legacy logs)",
|
|
380
|
+
log: loc,
|
|
381
|
+
});
|
|
382
|
+
} else {
|
|
383
|
+
const version = parseVersion(entry.version);
|
|
384
|
+
if (version === null || version <= 0) {
|
|
385
|
+
issues.push({
|
|
386
|
+
severity: machineEntry ? "error" : "warning",
|
|
387
|
+
code: "LOG_VERSION_INVALID",
|
|
388
|
+
message: "version must be a positive integer",
|
|
389
|
+
log: loc,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (!("version_url" in entry)) {
|
|
394
|
+
issues.push({
|
|
395
|
+
severity: machineEntry ? "error" : "warning",
|
|
396
|
+
code: "LOG_VERSION_URL_MISSING",
|
|
397
|
+
message: "version_url required when version is present",
|
|
398
|
+
log: loc,
|
|
399
|
+
});
|
|
400
|
+
} else if (typeof entry.version_url !== "string" || !entry.version_url.trim()) {
|
|
401
|
+
issues.push({
|
|
402
|
+
severity: machineEntry ? "error" : "warning",
|
|
403
|
+
code: "LOG_VERSION_URL_INVALID",
|
|
404
|
+
message: "version_url must be a non-empty string",
|
|
405
|
+
log: loc,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if ("ts" in entry && !parseIso(entry.ts)) {
|
|
411
|
+
issues.push({
|
|
412
|
+
severity: machineEntry ? "error" : "warning",
|
|
413
|
+
code: "TS_INVALID",
|
|
414
|
+
message: "ts not ISO8601",
|
|
415
|
+
log: loc,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if ("run_started" in entry) {
|
|
420
|
+
if (!parseIso(entry.run_started)) {
|
|
421
|
+
issues.push({
|
|
422
|
+
severity: machineEntry ? "error" : "warning",
|
|
423
|
+
code: "RUN_STARTED_INVALID",
|
|
424
|
+
message: "run_started not ISO8601",
|
|
425
|
+
log: loc,
|
|
426
|
+
});
|
|
427
|
+
} else {
|
|
428
|
+
if (!runStartedValue) {
|
|
429
|
+
runStartedValue = entry.run_started;
|
|
430
|
+
} else if (runStartedValue !== entry.run_started) {
|
|
431
|
+
issues.push({
|
|
432
|
+
severity: machineEntry ? "error" : "warning",
|
|
433
|
+
code: "RUN_STARTED_INCONSISTENT",
|
|
434
|
+
message: "run_started differs within file",
|
|
435
|
+
log: loc,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (
|
|
440
|
+
expectedPrefix &&
|
|
441
|
+
!entry.run_started.replaceAll(":", "").startsWith(expectedPrefix.replaceAll(":", ""))
|
|
442
|
+
) {
|
|
443
|
+
issues.push({
|
|
444
|
+
severity: "warning",
|
|
445
|
+
code: "RUN_STARTED_FILENAME_MISMATCH",
|
|
446
|
+
message: "run_started mismatch filename prefix",
|
|
447
|
+
log: loc,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if ("actor_type" in entry && !["human", "agent"].includes(entry.actor_type)) {
|
|
454
|
+
issues.push({
|
|
455
|
+
severity: machineEntry ? "error" : "warning",
|
|
456
|
+
code: "ACTOR_TYPE_INVALID",
|
|
457
|
+
message: "actor_type must be human|agent",
|
|
458
|
+
log: loc,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (machineEntry && entry.written_by !== "tickets" && entry.machine !== true) {
|
|
463
|
+
issues.push({
|
|
464
|
+
severity: "error",
|
|
465
|
+
code: "MACHINE_MARKER_MISSING",
|
|
466
|
+
message: "machine marker required",
|
|
467
|
+
log: loc,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if ("custom" in entry && (typeof entry.custom !== "object" || !entry.custom || Array.isArray(entry.custom))) {
|
|
472
|
+
issues.push({
|
|
473
|
+
severity: machineEntry ? "error" : "warning",
|
|
474
|
+
code: "LOG_CUSTOM_INVALID",
|
|
475
|
+
message: "custom must be mapping",
|
|
476
|
+
log: loc,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
return issues;
|
|
482
|
+
}
|