@gallopsystems/agent-skills 1.6.1 → 1.8.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.
@@ -121,6 +121,59 @@ const complexFilter = await db
121
121
  )
122
122
  .execute();
123
123
 
124
+ // ============================================
125
+ // BETWEEN — use eb.between(), NOT the "between" operator
126
+ // ============================================
127
+
128
+ // CORRECT: eb.between(col, lo, hi) / eb.betweenSymmetric(col, lo, hi)
129
+ const midPriced = await db
130
+ .selectFrom("product")
131
+ .selectAll()
132
+ .where((eb) => eb.between("price", "50", "100"))
133
+ .execute();
134
+ // SQL: where "price" between $1 and $2
135
+
136
+ // betweenSymmetric handles bounds given in either order (swaps if lo > hi)
137
+ const inRange = await db
138
+ .selectFrom("product")
139
+ .selectAll()
140
+ .where((eb) => eb.betweenSymmetric("price", "100", "50"))
141
+ .execute();
142
+ // SQL: where "price" between symmetric $1 and $2
143
+
144
+ // GOTCHA: the string-operator form generates INVALID SQL — do NOT use it.
145
+ // .where("price", "between", ["50", "100"])
146
+ // compiles to: "price" between ($1, $2) -- a tuple, which is a Postgres
147
+ // syntax error ("between (a, b)" is not "between a and b"). Always use
148
+ // eb.between() / eb.betweenSymmetric() instead.
149
+
150
+ // ============================================
151
+ // ANY (array / subquery membership)
152
+ // ============================================
153
+
154
+ // value = ANY(array_column) — type-safe with eb.fn.any
155
+ const taggedPremium = await db
156
+ .selectFrom("product")
157
+ .selectAll()
158
+ .where((eb) => eb(eb.val("premium"), "=", eb.fn.any("tags")))
159
+ .execute();
160
+ // SQL: where $1 = any("tags")
161
+
162
+ // value = ANY(subquery)
163
+ const ownersOfDogs = await db
164
+ .selectFrom("user")
165
+ .selectAll()
166
+ .where((eb) =>
167
+ eb(
168
+ eb.val("dog"),
169
+ "=",
170
+ eb.fn.any(
171
+ eb.selectFrom("pet").select("species").whereRef("pet.owner_id", "=", "user.id")
172
+ )
173
+ )
174
+ )
175
+ .execute();
176
+
124
177
  // ============================================
125
178
  // KEY PATTERNS SUMMARY
126
179
  // ============================================
@@ -143,4 +196,10 @@ const complexFilter = await db
143
196
  4. eb() inside where callbacks
144
197
  - eb("column", "=", value) creates comparison
145
198
  - Returns Expression<SqlBool> for composability
199
+
200
+ 5. BETWEEN: use eb.between(col, lo, hi) / eb.betweenSymmetric(...)
201
+ - The "between" string operator emits invalid SQL ("between (a, b)") — avoid.
202
+
203
+ 6. ANY: eb(eb.val(x), "=", eb.fn.any("array_col" | subquery))
204
+ - Membership test against an array column or a subquery result.
146
205
  */
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Set Operations (UNION / INTERSECT / EXCEPT)
3
+ * Combine the rows of two compatible queries.
4
+ *
5
+ * .union(q) / .unionAll(q) -> UNION / UNION ALL
6
+ * .intersect(q) / .intersectAll(q) -> INTERSECT / INTERSECT ALL
7
+ * .except(q) / .exceptAll(q) -> EXCEPT / EXCEPT ALL
8
+ *
9
+ * The plain forms remove duplicates; the *All forms keep them (and are cheaper).
10
+ * Both sides must select the same number of columns with compatible types and
11
+ * matching output names — align them with `as` aliases when they differ.
12
+ */
13
+ import { db } from "./db";
14
+
15
+ // ============================================
16
+ // UNION — distinct rows from both queries
17
+ // ============================================
18
+
19
+ // Align column names with aliases so both branches produce the same shape.
20
+ const contacts = await db
21
+ .selectFrom("user")
22
+ .select(["id", "email as contact"])
23
+ .union(db.selectFrom("supplier").select(["id", "contact_email as contact"]))
24
+ .execute();
25
+ // SQL: select "id", "email" as "contact" from "user"
26
+ // union select "id", "contact_email" as "contact" from "supplier"
27
+
28
+ // UNION ALL — keep duplicates (faster; no dedup pass)
29
+ const allEvents = await db
30
+ .selectFrom("order")
31
+ .select(["id", "created_at"])
32
+ .unionAll(db.selectFrom("review").select(["id", "created_at"]))
33
+ .execute();
34
+
35
+ // ============================================
36
+ // INTERSECT — rows present in BOTH queries
37
+ // ============================================
38
+
39
+ const usersWhoAreAlsoReviewers = await db
40
+ .selectFrom("order")
41
+ .select("user_id")
42
+ .intersect(db.selectFrom("review").select("user_id"))
43
+ .execute();
44
+
45
+ // INTERSECT ALL keeps duplicate matches
46
+ const repeated = await db
47
+ .selectFrom("order")
48
+ .select("user_id")
49
+ .intersectAll(db.selectFrom("review").select("user_id"))
50
+ .execute();
51
+
52
+ // ============================================
53
+ // EXCEPT — rows in the FIRST query but not the second
54
+ // ============================================
55
+
56
+ // Users who placed an order but never wrote a review.
57
+ const ordersWithoutReviews = await db
58
+ .selectFrom("order")
59
+ .select("user_id")
60
+ .except(db.selectFrom("review").select("user_id"))
61
+ .execute();
62
+
63
+ const ordersWithoutReviewsKeepDupes = await db
64
+ .selectFrom("order")
65
+ .select("user_id")
66
+ .exceptAll(db.selectFrom("review").select("user_id"))
67
+ .execute();
68
+
69
+ // ============================================
70
+ // ORDERING THE COMBINED RESULT
71
+ // ============================================
72
+
73
+ // orderBy/limit after a set op apply to the whole combined result.
74
+ const recentCombined = await db
75
+ .selectFrom("order")
76
+ .select(["id", "created_at"])
77
+ .unionAll(db.selectFrom("review").select(["id", "created_at"]))
78
+ .orderBy("created_at", "desc")
79
+ .limit(20)
80
+ .execute();
81
+
82
+ // ============================================
83
+ // KEY PATTERNS SUMMARY
84
+ // ============================================
85
+
86
+ /*
87
+ 1. Both branches must select the same columns (count + compatible types) with
88
+ matching output names — use `as` to line them up.
89
+
90
+ 2. Plain forms dedupe; *All forms keep duplicates and skip the dedup pass.
91
+ Reach for unionAll/intersectAll/exceptAll unless you actually need DISTINCT.
92
+
93
+ 3. EXCEPT/INTERSECT are set-difference / set-intersection on whole rows.
94
+
95
+ 4. orderBy/limit chained after the set op apply to the combined result, not the
96
+ individual branches.
97
+ */
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Window Functions
3
+ * Ranking, value, and aggregate functions over OVER() windows.
4
+ *
5
+ * Two builders cover almost everything:
6
+ * - eb.fn.agg<T>("NAME", [args]).over(...) for named window functions
7
+ * (ROW_NUMBER, RANK, LAG, ...) that have no dedicated helper
8
+ * - eb.fn.sum/count/avg/min/max(col).over(...) for windowed aggregates
9
+ *
10
+ * The OVER() body is built with a callback: (ob) => ob.partitionBy(...).orderBy(...)
11
+ * Call .over() with no callback for an empty OVER ().
12
+ *
13
+ * The one thing the builder CANNOT express is a frame clause
14
+ * (ROWS/RANGE/GROUPS BETWEEN) — see WINDOW FRAMES below.
15
+ */
16
+ import { db } from "./db";
17
+ import { sql } from "kysely";
18
+
19
+ // ============================================
20
+ // RANKING FUNCTIONS
21
+ // ============================================
22
+
23
+ // ROW_NUMBER / RANK / DENSE_RANK — use eb.fn.agg (no dedicated helper)
24
+ // Rank products by price within each category.
25
+ const rankedProducts = await db
26
+ .selectFrom("product")
27
+ .select((eb) => [
28
+ "id",
29
+ "name",
30
+ "category_id",
31
+ eb.fn
32
+ .agg<number>("ROW_NUMBER")
33
+ .over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
34
+ .as("row_num"),
35
+ eb.fn
36
+ .agg<number>("RANK")
37
+ .over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
38
+ .as("price_rank"),
39
+ eb.fn
40
+ .agg<number>("DENSE_RANK")
41
+ .over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
42
+ .as("dense_rank"),
43
+ ])
44
+ .execute();
45
+ // SQL: ROW_NUMBER() over(partition by "category_id" order by "price" desc) as "row_num"
46
+
47
+ // NTILE / PERCENT_RANK / CUME_DIST — NTILE takes an argument (use sql.lit)
48
+ const productBuckets = await db
49
+ .selectFrom("product")
50
+ .select((eb) => [
51
+ "id",
52
+ eb.fn
53
+ .agg<number>("NTILE", [sql.lit(4)])
54
+ .over((ob) => ob.orderBy("price", "desc"))
55
+ .as("price_quartile"),
56
+ eb.fn
57
+ .agg<number>("PERCENT_RANK")
58
+ .over((ob) => ob.orderBy("price", "desc"))
59
+ .as("pct_rank"),
60
+ ])
61
+ .execute();
62
+ // SQL: NTILE(4) over(order by "price" desc) as "price_quartile"
63
+
64
+ // ============================================
65
+ // VALUE FUNCTIONS (LAG / LEAD / FIRST_VALUE / NTH_VALUE)
66
+ // ============================================
67
+
68
+ // LAG / LEAD — args: (column, offset?, default?). Wrap literals in sql.lit().
69
+ // Type the result yourself; nullable unless you supply a default.
70
+ const orderTrends = await db
71
+ .selectFrom("order")
72
+ .select((eb) => [
73
+ "id",
74
+ "created_at",
75
+ "total_amount",
76
+ eb.fn
77
+ .agg<number | null>("LAG", ["total_amount", sql.lit(1)])
78
+ .over((ob) => ob.partitionBy("user_id").orderBy("created_at"))
79
+ .as("prev_amount"),
80
+ eb.fn
81
+ .agg<number | null>("LEAD", ["total_amount", sql.lit(1)])
82
+ .over((ob) => ob.partitionBy("user_id").orderBy("created_at"))
83
+ .as("next_amount"),
84
+ // With a default value (no nulls): LAG(total_amount, 1, 0)
85
+ eb.fn
86
+ .agg<number>("LAG", ["total_amount", sql.lit(1), sql.lit(0)])
87
+ .over((ob) => ob.partitionBy("user_id").orderBy("created_at"))
88
+ .as("prev_amount_or_zero"),
89
+ ])
90
+ .execute();
91
+ // SQL: LAG("total_amount", 1, 0) over(partition by "user_id" order by "created_at") as "prev_amount_or_zero"
92
+
93
+ // FIRST_VALUE / NTH_VALUE
94
+ const categoryExtremes = await db
95
+ .selectFrom("product")
96
+ .select((eb) => [
97
+ "id",
98
+ "category_id",
99
+ eb.fn
100
+ .agg<number>("FIRST_VALUE", ["price"])
101
+ .over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
102
+ .as("highest_in_category"),
103
+ eb.fn
104
+ .agg<number | null>("NTH_VALUE", ["price", sql.lit(2)])
105
+ .over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
106
+ .as("second_highest"),
107
+ ])
108
+ .execute();
109
+
110
+ // ============================================
111
+ // AGGREGATE WINDOWS (sum/count/avg/min/max .over())
112
+ // ============================================
113
+
114
+ // Empty OVER () — aggregate over the whole result set, no collapsing of rows
115
+ const withGrandTotal = await db
116
+ .selectFrom("order")
117
+ .select((eb) => [
118
+ "id",
119
+ "total_amount",
120
+ eb.fn.sum<number>("total_amount").over().as("grand_total"),
121
+ ])
122
+ .execute();
123
+ // SQL: sum("total_amount") over() as "grand_total"
124
+
125
+ // Running total — ORDER BY without a frame defaults to
126
+ // "RANGE UNBOUNDED PRECEDING -> CURRENT ROW" (cumulative)
127
+ const runningTotals = await db
128
+ .selectFrom("order")
129
+ .select((eb) => [
130
+ "id",
131
+ "created_at",
132
+ "total_amount",
133
+ eb.fn
134
+ .sum<number>("total_amount")
135
+ .over((ob) => ob.orderBy("created_at"))
136
+ .as("running_total"),
137
+ ])
138
+ .execute();
139
+ // SQL: sum("total_amount") over(order by "created_at") as "running_total"
140
+
141
+ // Partition total + percent-of-partition (mix a window into a raw expression)
142
+ const sharePerCategory = await db
143
+ .selectFrom("product")
144
+ .select((eb) => [
145
+ "id",
146
+ "category_id",
147
+ "price",
148
+ eb.fn
149
+ .sum<number>("price")
150
+ .over((ob) => ob.partitionBy("category_id"))
151
+ .as("category_total"),
152
+ sql<number>`${eb.ref("price")} * 100.0 / ${eb.fn
153
+ .sum("price")
154
+ .over((ob) => ob.partitionBy("category_id"))}`.as("pct_of_category"),
155
+ ])
156
+ .execute();
157
+
158
+ // ============================================
159
+ // FILTER + DISTINCT IN WINDOWS (PostgreSQL)
160
+ // ============================================
161
+
162
+ // .filterWhere() composes with .over() — conditional windowed aggregation
163
+ const activeCounts = await db
164
+ .selectFrom("order")
165
+ .select((eb) => [
166
+ "id",
167
+ "user_id",
168
+ eb.fn
169
+ .countAll<number>()
170
+ .filterWhere("status", "=", "completed")
171
+ .over((ob) => ob.partitionBy("user_id"))
172
+ .as("completed_orders_for_user"),
173
+ ])
174
+ .execute();
175
+ // SQL: count(*) filter(where "status" = $1) over(partition by "user_id")
176
+
177
+ // COUNT(DISTINCT ...) OVER (...)
178
+ const distinctStatuses = await db
179
+ .selectFrom("order")
180
+ .select((eb) => [
181
+ "id",
182
+ "user_id",
183
+ eb.fn
184
+ .count<number>("status")
185
+ .distinct()
186
+ .over((ob) => ob.partitionBy("user_id"))
187
+ .as("distinct_statuses_for_user"),
188
+ ])
189
+ .execute();
190
+ // SQL: count(distinct "status") over(partition by "user_id")
191
+
192
+ // ============================================
193
+ // WINDOW FRAMES (ROWS/RANGE BETWEEN) — RAW SQL REQUIRED
194
+ // ============================================
195
+
196
+ // Kysely's OverBuilder exposes ONLY partitionBy + orderBy. There is no frame
197
+ // node in its AST at all (confirmed through 0.28, 0.29, and main), and
198
+ // .over() rejects a raw sql argument. Frame support is the open feature
199
+ // request kysely-org/kysely#505. So a frame clause must be written as raw SQL.
200
+ //
201
+ // You do NOT lose all type safety: interpolate eb.ref() for columns (validated)
202
+ // and sql.lit() for bounds, and type the result with sql<T>. Only the function
203
+ // name and the frame keywords are raw text.
204
+
205
+ // Moving average over the last 3 rows
206
+ const movingAverage = await db
207
+ .selectFrom("order")
208
+ .select((eb) => [
209
+ "id",
210
+ "created_at",
211
+ "total_amount",
212
+ sql<number>`avg(${eb.ref("total_amount")}) over (
213
+ order by ${eb.ref("created_at")}
214
+ rows between 2 preceding and current row
215
+ )`.as("moving_avg_3"),
216
+ ])
217
+ .execute();
218
+ // SQL: avg("total_amount") over ( order by "created_at" rows between 2 preceding and current row )
219
+
220
+ // Cumulative sum with an explicit frame
221
+ const cumulative = await db
222
+ .selectFrom("order")
223
+ .select((eb) => [
224
+ "id",
225
+ sql<number>`sum(${eb.ref("total_amount")}) over (
226
+ partition by ${eb.ref("user_id")}
227
+ order by ${eb.ref("created_at")}
228
+ rows between unbounded preceding and current row
229
+ )`.as("cumulative_sum"),
230
+ ])
231
+ .execute();
232
+
233
+ // ============================================
234
+ // COMMON PATTERNS
235
+ // ============================================
236
+
237
+ // Top-N per group: rank in a CTE, then filter in the outer query.
238
+ // (You can't filter on a window alias in the same SELECT's WHERE — windows are
239
+ // computed after WHERE — so the CTE/subquery wrapper is required.)
240
+ const top3ProductsPerCategory = await db
241
+ .with("ranked", (db) =>
242
+ db
243
+ .selectFrom("product")
244
+ .select((eb) => [
245
+ "id",
246
+ "name",
247
+ "category_id",
248
+ "price",
249
+ eb.fn
250
+ .agg<number>("ROW_NUMBER")
251
+ .over((ob) => ob.partitionBy("category_id").orderBy("price", "desc"))
252
+ .as("rn"),
253
+ ])
254
+ )
255
+ .selectFrom("ranked")
256
+ .selectAll()
257
+ .where("rn", "<=", 3)
258
+ .execute();
259
+
260
+ // Percent of total (empty OVER () as the denominator)
261
+ const pctOfTotal = await db
262
+ .selectFrom("product")
263
+ .select((eb) => [
264
+ "id",
265
+ "price",
266
+ sql<number>`round(${eb.ref("price")} * 100.0 / ${eb.fn
267
+ .sum("price")
268
+ .over()}, 2)`.as("pct_of_total"),
269
+ ])
270
+ .execute();
271
+
272
+ // ============================================
273
+ // KEY PATTERNS SUMMARY
274
+ // ============================================
275
+
276
+ /*
277
+ 1. Named window functions: eb.fn.agg<T>("ROW_NUMBER").over((ob) => ...)
278
+ - ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK, CUME_DIST
279
+ - LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE
280
+ - Function arguments go in the array; wrap literals in sql.lit().
281
+
282
+ 2. Windowed aggregates: eb.fn.sum/count/avg/min/max(col).over((ob) => ...)
283
+ - .over() with no callback => OVER () over the whole result set.
284
+ - .filterWhere(...) and .distinct() compose with .over().
285
+
286
+ 3. OVER body: (ob) => ob.partitionBy(col | [cols]).orderBy(col, "desc")
287
+
288
+ 4. Frames (ROWS/RANGE BETWEEN) are NOT supported by the builder (issue #505).
289
+ Write the window as raw sql, keeping eb.ref() for columns and sql<T> for the
290
+ result type. Only the function name + frame keywords are raw.
291
+
292
+ 5. Filtering on a window result (e.g. row_number = 1) needs a CTE/subquery:
293
+ windows are computed after WHERE, so wrap and filter in the outer query.
294
+ */
@@ -76,7 +76,7 @@ From nuxt-auth-utils:
76
76
  - `hashPassword`, `verifyPassword`
77
77
  - `defineOAuth*EventHandler` (Google, GitHub, etc.)
78
78
 
79
- **Need to import:** `z` from "zod", `fromZodError` from "zod-validation-error"
79
+ **Need to import:** `z` from "zod" (zod 4 — error formatting is built in via `z.prettifyError()`; no `zod-validation-error` needed)
80
80
 
81
81
  ### Client-side
82
82
 
@@ -104,7 +104,7 @@ const query = await getValidatedQuery(event, z.object({
104
104
  }));
105
105
 
106
106
  const body = await readValidatedBody(event, z.object({
107
- email: z.string().email(),
107
+ email: z.email(), // Zod 4: top-level, not z.string().email()
108
108
  name: z.string().min(1),
109
109
  }));
110
110
  ```
@@ -33,7 +33,7 @@ Tasks live in `server/tasks/`. Directory structure = task name with colons:
33
33
  import { z } from "zod";
34
34
 
35
35
  const PayloadSchema = z.object({
36
- to: z.string().email(),
36
+ to: z.email(), // Zod 4: top-level, not z.string().email()
37
37
  subject: z.string(),
38
38
  body: z.string(),
39
39
  });
@@ -43,25 +43,28 @@ const query = await getValidatedQuery(event, (data) => querySchema.parse(data));
43
43
 
44
44
  ## Pattern 3: safeParse for Better Errors
45
45
 
46
+ Zod 4 has a built-in `z.prettifyError()` — no `zod-validation-error` dependency needed:
47
+
46
48
  ```typescript
47
- import { fromZodError } from "zod-validation-error";
49
+ import { z } from "zod";
48
50
 
49
51
  const rawQuery = getQuery(event);
50
52
  const result = querySchema.safeParse(rawQuery);
51
53
 
52
54
  if (!result.success) {
53
- console.error("Validation error:", result.error); // Dev log
54
- const userError = fromZodError(result.error); // User-friendly
55
+ console.error("Validation error:", z.treeifyError(result.error)); // structured dev log
55
56
  throw createError({
56
57
  statusCode: 400,
57
58
  statusMessage: "Bad Request",
58
- message: userError.message,
59
+ message: z.prettifyError(result.error), // human-readable, e.g. "✖ Invalid email address\n → at email"
59
60
  });
60
61
  }
61
62
 
62
63
  return result.data;
63
64
  ```
64
65
 
66
+ `z.prettifyError(err)` returns a readable multi-line string; `z.treeifyError(err)` returns a nested object keyed by field (the replacement for the deprecated `.format()`). Use `z.flattenError(err)` for a flat `{ formErrors, fieldErrors }` shape.
67
+
65
68
  ## Common Zod Patterns
66
69
 
67
70
  ### Query Parameters
@@ -90,7 +93,7 @@ const querySchema = z.object({
90
93
 
91
94
  ```typescript
92
95
  const createUserSchema = z.object({
93
- email: z.string().email(),
96
+ email: z.email(), // Zod 4: top-level, NOT z.string().email()
94
97
  name: z.string().min(1).max(100),
95
98
  role: z.enum(["admin", "user"]).default("user"),
96
99
  metadata: z.record(z.string(), z.any()).optional(),
@@ -117,7 +120,7 @@ Export schemas for client-side type reuse:
117
120
  import { z } from "zod";
118
121
 
119
122
  export const CreateUserSchema = z.object({
120
- email: z.string().email(),
123
+ email: z.email(),
121
124
  name: z.string().min(1),
122
125
  });
123
126
 
@@ -129,3 +132,17 @@ const body: CreateUserInput = { email: "test@example.com", name: "Test" };
129
132
  ```
130
133
 
131
134
  **Note:** Nitro auto-generates response types, but NOT input types from Zod schemas.
135
+
136
+ ## Zod 4 Notes (this stack pins zod ^4)
137
+
138
+ The model's prior is mostly Zod 3 — these are the idioms that changed. Get them right.
139
+
140
+ - **String formats are top-level functions, not `z.string()` methods.** Use `z.email()`, `z.url()`, `z.uuid()`, `z.ipv4()`, `z.iso.datetime()`. The chained forms (`z.string().email()`) are deprecated.
141
+ - **Error customization is one `error` param.** `message`, `invalid_type_error`, `required_error`, and `errorMap` are gone:
142
+ ```typescript
143
+ z.string().min(5, { error: "Too short." });
144
+ z.string({ error: (iss) => iss.input === undefined ? "Required" : "Must be a string" });
145
+ ```
146
+ - **Format errors with the built-ins**, not `zod-validation-error`: `z.prettifyError()` (human string), `z.treeifyError()` (nested, replaces deprecated `.format()`), `z.flattenError()` (replaces deprecated `.flatten()`).
147
+ - **`.default()` applies to the *output* type** and short-circuits parsing when input is `undefined`. For the old "run the default through the schema" behavior, use `.prefault()`.
148
+ - **`z.coerce.*` input type is now `unknown`** (not the output type) — fine for h3 query/body parsing, but affects schemas you consume elsewhere.
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "tailwind-v4",
3
+ "description": "Tailwind CSS v4 (CSS-first): @import/@theme/@source config, the no-tailwind.config.js model, prefers-color-scheme dark mode, and the v3->v4 traps an LLM trained on v3 falls into",
4
+ "version": "1.0.0",
5
+ "author": {
6
+ "name": "yeedle"
7
+ }
8
+ }