@adibacsi/pi-jack 1.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
+ [semantic versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ Nothing yet.
10
+
11
+ ## [1.0.0] - 2025-10-04
12
+
13
+ First release.
14
+
15
+ ### Added
16
+
17
+ - The `jack` tool: delegates a task, or a batch of tasks, to subagents that are isolated `pi` subprocesses and must
18
+ answer in a JSON Schema.
19
+ - Named agents, resolved from the built-in `agents/` directory and from `~/.pi/agent/agents/`; a user's agent of the
20
+ same name replaces a built-in one. `worker` is the default, `tester` ships alongside it.
21
+ - Per-call overrides for the agent's prompt (`system_prompt`), tools, model, and thinking level.
22
+ - `run_mode: 'sequential' | 'parallel'` for batches, with live per-task progress and an optional `debug_mode` that
23
+ reports how each subagent was set up and the exact `pi` command line it ran with.
24
+ - `--json-schema <file>`: makes any `pi` run finish with an answer conforming to a JSON Schema, through the
25
+ `jack_subagent_result` / `jack_subagent_fail` tools. This is how every subagent is started, and it works standalone.
26
+ - A parent stops a child after `MAX_FORMAT_RETRIES` rejected answers, counting both Pi's own argument validation and
27
+ the tool's re-check.
28
+ - The `/jack-demo` prompt template.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adam Jakab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # JACK: JSON Agent Contractor Kit
2
+
3
+ A [Pi](https://pi.dev) extension for getting structured answers from agents. It adds:
4
+
5
+ - **The `jack` tool.** The model delegates one task, or a batch run sequentially or in parallel, to subagents.
6
+ Each subagent is an isolated `pi` process, and its answer conforms to a JSON Schema you choose.
7
+ - **The `--json-schema <schema>` flag.** It makes any `pi` run finish with an answer that conforms to a schema. The
8
+ name matches Claude Code's flag for the same purpose.
9
+ - **The `/jack-demo [path]` prompt.** Four subagents survey a folder in parallel while you watch.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pi install npm:@adibacsi/pi-jack # from npm
15
+ pi install git:github.com/adamjakab/pi-jack # from GitHub
16
+ pi install /path/to/pi-jack # from a local checkout
17
+ ```
18
+
19
+ You can also put the folder, or a symlink to it, in `~/.pi/agent/extensions/`.
20
+
21
+ ## The `jack` tool
22
+
23
+ | Parameter | Meaning |
24
+ | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
25
+ | `task`, or `tasks` + `run_mode` | One task, or a list run `sequential` (default) or `parallel`. Each item in `tasks` can override `agent`, `model`, `thinking` and the other fields. |
26
+ | `agent` | A named agent (see below). Leave it out to use the default `worker` agent. |
27
+ | `schema` | The JSON Schema the answer must conform to: an object, inline JSON, or a path to a `.json` file. |
28
+ | `model` | The model, as for `pi --model`. Overrides the agent's model. |
29
+ | `thinking` | `off`, `low`, `medium`, `high`, `xhigh` or `max`. Overrides the agent's level. |
30
+ | `system_prompt`, `tools` | Only apply when no agent is named: extra system prompt and a tool list for the default agent. |
31
+ | `debug_mode` | Shows each subagent's resolved setup (agent, prompt, model, thinking, tools, schema, command line) and what it actually ran with. |
32
+
33
+ The tool returns each subagent's validated answer as `data`, plus `success`, `error`, `attempts`, token usage, and
34
+ `ranWith` (the model and thinking level the subagent actually used).
35
+
36
+ ### Thinking levels
37
+
38
+ The level is passed to the subagent as `pi --thinking`. When the model doesn't support it, Pi uses the nearest
39
+ higher level the model does support, or else the nearest lower one. Some models can't switch thinking off at all,
40
+ so `off` runs at their lowest level.
41
+
42
+ ## Agents
43
+
44
+ An agent is a Markdown file with YAML frontmatter. The body becomes the subagent's system prompt.
45
+
46
+ ```markdown
47
+ ---
48
+ name: reviewer
49
+ description: Reviews a diff and reports problems.
50
+ tools: read, grep, find, ls
51
+ model: openrouter/~anthropic/claude-haiku-latest
52
+ thinking: medium
53
+ schema: schemas/review.json
54
+ ---
55
+
56
+ You review code changes...
57
+ ```
58
+
59
+ `name` and `description` are required. `tools`, `model`, `thinking` and `schema` are optional. `schema` can be YAML,
60
+ inline JSON, or a path relative to the agent file.
61
+
62
+ - **Built-in agents** ship in this repo's `agents/` folder: `worker` (the default) and `tester`.
63
+ - **Your agents** go in `~/.pi/agent/agents/`. One with the same name as a built-in agent replaces it.
64
+ - **Broken files:** an agent file that fails to load is reported and skipped. A call that names it fails instead of
65
+ quietly falling back to another agent.
66
+
67
+ ## The output contract
68
+
69
+ Every subagent answers in a JSON Schema. JACK picks the call's `schema`, else the agent's `schema`, else a default
70
+ that asks for `success` (boolean) and `result` (string).
71
+
72
+ - **Shape:** the root must describe an object.
73
+ - **Descriptions:** each property's `description` is how the subagent learns what goes there, so write them carefully.
74
+ - **Checked first:** a schema is checked before any subagent starts. Unknown keywords, unknown type names and
75
+ wrongly-shaped values (usually typos) are refused rather than silently ignored.
76
+
77
+ The answer is collected through `--json-schema`. That flag gives the run a `jack_subagent_result` tool whose
78
+ parameters are the schema, with provider-side constrained sampling where the provider supports it. It also adds a
79
+ `jack_subagent_fail` tool for giving up with a reason.
80
+
81
+ - **Invalid answers** are rejected with their errors, and the subagent fixes them in the same run.
82
+ - **Missing answers:** a subagent that stops without answering is reminded to answer.
83
+ - **Second check:** JACK validates the answer again in the parent. It stops a subagent after too many rejected answers.
84
+
85
+ ### `--json-schema` without the tool
86
+
87
+ ```bash
88
+ pi --mode json -p --json-schema ./schema.json "Summarize this repository"
89
+ pi --mode json -p --json-schema '{"type":"object","properties":{"answer":{"type":"string"}},"required":["answer"]}' "…"
90
+ ```
91
+
92
+ The answer is `result.details` of the last successful `tool_execution_end` event for `jack_subagent_result`.
93
+
94
+ ## License
95
+
96
+ [MIT](LICENSE)
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: tester
3
+ description: Subagent for jack running in isolated context cabable of code validation.
4
+ tools: read, bash, grep, find, ls
5
+ ---
6
+
7
+ # Worker Agent
8
+
9
+ You are a code verification agent with non-desturctive capabilities. You operate in an isolated context window to handle delegated tasks without polluting the main conversation. Work autonomously to complete the assigned task. Use all available tools as needed.
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: worker
3
+ description: Default subagent for jack - General-purpose, full capabilities, isolated context.
4
+ tools: read, bash, edit, write, grep, find, ls
5
+ ---
6
+
7
+ # Worker Agent
8
+
9
+ You are a worker agent with full capabilities. You operate in an isolated context window to handle
10
+ delegated tasks without polluting the main conversation. Work autonomously to complete the assigned task.
11
+ Use all available tools as needed.
package/agents.ts ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Agent discovery. Loads the agents built into this extension (its own `agents/` folder, e.g. the default `worker`)
3
+ * and the user's agents (~/.pi/agent/agents/*.md). A user agent with the same name as a built-in one replaces it.
4
+ */
5
+
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
10
+ import {
11
+ isThinkingLevel,
12
+ THINKING_LEVELS,
13
+ type ThinkingLevel,
14
+ } from "./contract.ts";
15
+
16
+ /** Folder of the agents that ship with this extension. */
17
+ export const BUILT_IN_AGENTS_DIR = fileURLToPath(
18
+ new URL("./agents", import.meta.url),
19
+ );
20
+
21
+ /** Where an agent comes from: this extension, or the user's agents folder. */
22
+ export type AgentSource = "built-in" | "user";
23
+
24
+ export interface AgentConfig {
25
+ name: string;
26
+ description: string;
27
+ tools?: string[];
28
+ model?: string;
29
+ /** Default thinking level; pi moves an unsupported one to the nearest level the model supports. */
30
+ thinking?: ThinkingLevel;
31
+ /**
32
+ * Default output schema, used when the call doesn't pass one: a JSON Schema written as YAML, inline JSON, or a
33
+ * path to a `.json` file relative to `dir`. Resolved by contract.ts's resolveSchema().
34
+ */
35
+ schema?: unknown;
36
+ /** Folder of the agent file, which relative paths in its frontmatter resolve against. */
37
+ dir: string;
38
+ source: AgentSource;
39
+ /** Set on a user agent that replaces a built-in agent of the same name. */
40
+ overridesBuiltIn?: boolean;
41
+ systemPrompt: string;
42
+ }
43
+
44
+ function parseToolList(value: unknown): string[] | undefined {
45
+ const raw = Array.isArray(value)
46
+ ? value
47
+ : typeof value === "string"
48
+ ? value.split(",")
49
+ : [];
50
+ const tools = raw
51
+ .filter((t): t is string => typeof t === "string")
52
+ .map((t) => t.trim())
53
+ .filter(Boolean);
54
+ return tools.length > 0 ? tools : undefined;
55
+ }
56
+
57
+ function parseSchema(value: unknown): unknown {
58
+ if (typeof value === "string") return value.trim() || undefined;
59
+ if (value && typeof value === "object") return value;
60
+ return undefined;
61
+ }
62
+
63
+ /** An agent file that could not be loaded, and why. */
64
+ export interface AgentLoadError {
65
+ /** The file's name, e.g. `worker.md`. */
66
+ file: string;
67
+ source: AgentSource;
68
+ message: string;
69
+ }
70
+
71
+ /**
72
+ * Loads the built-in agents, then the user's, which replace built-in agents of the same name. A file that can't be
73
+ * read or parsed is reported in `errors` instead of throwing, so one broken file never hides the others.
74
+ */
75
+ export function discoverAgents(builtInDir = BUILT_IN_AGENTS_DIR): {
76
+ agents: AgentConfig[];
77
+ errors: AgentLoadError[];
78
+ } {
79
+ const builtIn = loadAgentDir(builtInDir, "built-in");
80
+ const user = loadAgentDir(path.join(getAgentDir(), "agents"), "user");
81
+
82
+ const userNames = new Set(user.agents.map((a) => a.name));
83
+ const builtInNames = new Set(builtIn.agents.map((a) => a.name));
84
+ const agents = [
85
+ ...builtIn.agents.filter((a) => !userNames.has(a.name)),
86
+ ...user.agents.map((a) =>
87
+ builtInNames.has(a.name) ? { ...a, overridesBuiltIn: true } : a,
88
+ ),
89
+ ];
90
+ return { agents, errors: [...builtIn.errors, ...user.errors] };
91
+ }
92
+
93
+ function loadAgentDir(
94
+ dir: string,
95
+ source: AgentSource,
96
+ ): { agents: AgentConfig[]; errors: AgentLoadError[] } {
97
+ const agents: AgentConfig[] = [];
98
+ const errors: AgentLoadError[] = [];
99
+
100
+ if (!fs.existsSync(dir)) return { agents, errors };
101
+
102
+ let entries: fs.Dirent[];
103
+ try {
104
+ entries = fs.readdirSync(dir, { withFileTypes: true });
105
+ } catch (e) {
106
+ errors.push({
107
+ file: dir,
108
+ source,
109
+ message: e instanceof Error ? e.message : String(e),
110
+ });
111
+ return { agents, errors };
112
+ }
113
+
114
+ for (const entry of entries) {
115
+ if (!entry.name.endsWith(".md")) continue;
116
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
117
+
118
+ const filePath = path.join(dir, entry.name);
119
+ let parsed;
120
+ try {
121
+ parsed = parseFrontmatter<{
122
+ name?: unknown;
123
+ description?: unknown;
124
+ tools?: unknown;
125
+ model?: unknown;
126
+ thinking?: unknown;
127
+ schema?: unknown;
128
+ }>(fs.readFileSync(filePath, "utf-8"));
129
+ } catch (e) {
130
+ // Keep only the first line: YAML errors go on to draw the offending line with a caret.
131
+ const message = (e instanceof Error ? e.message : String(e))
132
+ .split("\n")[0]
133
+ .replace(/:$/, "");
134
+ errors.push({ file: entry.name, source, message });
135
+ continue;
136
+ }
137
+ const { frontmatter, body } = parsed;
138
+
139
+ if (
140
+ typeof frontmatter.name !== "string" ||
141
+ typeof frontmatter.description !== "string"
142
+ ) {
143
+ errors.push({
144
+ file: entry.name,
145
+ source,
146
+ message: "the frontmatter needs a `name` and a `description`",
147
+ });
148
+ continue;
149
+ }
150
+ if (
151
+ frontmatter.thinking !== undefined &&
152
+ !isThinkingLevel(frontmatter.thinking)
153
+ ) {
154
+ const levels = THINKING_LEVELS.join(", ");
155
+ errors.push({
156
+ file: entry.name,
157
+ source,
158
+ message: `\`thinking\` must be one of ${levels}`,
159
+ });
160
+ continue;
161
+ }
162
+
163
+ agents.push({
164
+ name: frontmatter.name,
165
+ description: frontmatter.description,
166
+ tools: parseToolList(frontmatter.tools),
167
+ model:
168
+ typeof frontmatter.model === "string" ? frontmatter.model : undefined,
169
+ thinking: frontmatter.thinking,
170
+ schema: parseSchema(frontmatter.schema),
171
+ dir,
172
+ source,
173
+ systemPrompt: body,
174
+ });
175
+ }
176
+
177
+ return { agents, errors };
178
+ }
package/contract.ts ADDED
@@ -0,0 +1,278 @@
1
+ /**
2
+ * The output contract between jack (the parent) and its subagents (child pi processes).
3
+ *
4
+ * A subagent answers by calling the `jack_subagent_result` tool, whose parameters are the run's JSON Schema, so each
5
+ * field's `description` reaches the model right where it fills that field. It gives up by calling `jack_subagent_fail`
6
+ * with a reason instead. Both tools live in json-schema.ts, behind the `--json-schema` flag; this module holds
7
+ * what both sides share: the tool names, the default schema, schema loading, validation, and thinking levels.
8
+ */
9
+
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import type { TSchema } from "typebox";
13
+ import { Value } from "typebox/value";
14
+
15
+ /** Tool a subagent calls with its answer; its parameters are the run's schema. */
16
+ export const RESULT_TOOL = "jack_subagent_result";
17
+
18
+ /** Tool a subagent calls, instead of RESULT_TOOL, when it cannot complete the task. */
19
+ export const FAIL_TOOL = "jack_subagent_fail";
20
+
21
+ /**
22
+ * CLI flag, registered by json-schema.ts, holding the JSON Schema the final answer must conform to. Named like Claude
23
+ * Code's flag of the same purpose. The parent passes each child the path of its schema file with it.
24
+ */
25
+ export const SCHEMA_FLAG = "json-schema";
26
+
27
+ /**
28
+ * Thinking levels a subagent can be asked for, lowest first. They are passed to the child as `--thinking`; for a
29
+ * level the model doesn't support, pi uses the nearest higher level it does, else the nearest lower one.
30
+ */
31
+ export const THINKING_LEVELS = [
32
+ "off",
33
+ "low",
34
+ "medium",
35
+ "high",
36
+ "xhigh",
37
+ "max",
38
+ ] as const;
39
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
40
+
41
+ export const isThinkingLevel = (value: unknown): value is ThinkingLevel =>
42
+ THINKING_LEVELS.includes(value as ThinkingLevel);
43
+
44
+ /**
45
+ * How many invalid answers or nudges a subagent gets after its first try. An invalid `jack_subagent_result` call is
46
+ * thrown back with its errors so the model can fix it; finishing without calling either tool earns a nudge.
47
+ */
48
+ export const MAX_FORMAT_RETRIES = 2;
49
+
50
+ /** Schema used when neither the call nor the agent gives one. */
51
+ export const DEFAULT_SCHEMA = {
52
+ type: "object",
53
+ properties: {
54
+ success: {
55
+ type: "boolean",
56
+ description:
57
+ "True only when all requested operations were finished and nothing is left to do.",
58
+ },
59
+ result: {
60
+ type: "string",
61
+ description:
62
+ "A concise description of what was found or achieved during this session.",
63
+ },
64
+ },
65
+ required: ["success", "result"],
66
+ additionalProperties: true,
67
+ } as const;
68
+
69
+ /**
70
+ * Turns a schema given as an object, inline JSON, or a path to a `.json` file into a JSON Schema object.
71
+ * Relative paths resolve against `baseDir`. Tool parameters must be an object, so the root must describe one.
72
+ * Throws with a message fit for the caller when the schema can't be used.
73
+ */
74
+ export function resolveSchema(value: unknown, baseDir: string): TSchema {
75
+ let schema = value;
76
+ if (typeof value === "string") {
77
+ const text = value.trim();
78
+ if (!text) throw new Error("the schema is empty");
79
+ if (text.startsWith("{")) {
80
+ try {
81
+ schema = JSON.parse(text);
82
+ } catch (e) {
83
+ throw new Error(
84
+ `the inline schema is not valid JSON (${e instanceof Error ? e.message : e})`,
85
+ );
86
+ }
87
+ } else {
88
+ const filePath = path.resolve(baseDir, text);
89
+ try {
90
+ schema = JSON.parse(fs.readFileSync(filePath, "utf-8"));
91
+ } catch (e) {
92
+ throw new Error(
93
+ `could not load the schema file ${filePath} (${e instanceof Error ? e.message : e})`,
94
+ );
95
+ }
96
+ }
97
+ }
98
+
99
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
100
+ throw new Error("the schema must be a JSON Schema object");
101
+ }
102
+ const root = schema as Record<string, unknown>;
103
+ if (root.type !== "object" && root.properties === undefined) {
104
+ throw new Error(
105
+ 'the root of the schema must describe an object, e.g. {"type": "object", "properties": {...}}',
106
+ );
107
+ }
108
+ const problems = schemaProblems(root);
109
+ if (problems.length > 0) throw new Error(problems.slice(0, 10).join("; "));
110
+ return root as TSchema;
111
+ }
112
+
113
+ const JSON_TYPES = [
114
+ "string",
115
+ "number",
116
+ "integer",
117
+ "boolean",
118
+ "object",
119
+ "array",
120
+ "null",
121
+ ];
122
+
123
+ type Check = (value: unknown, at: string) => string[];
124
+
125
+ const isObject = (v: unknown): v is Record<string, unknown> =>
126
+ typeof v === "object" && v !== null && !Array.isArray(v);
127
+ const must = (ok: boolean, at: string, what: string): string[] =>
128
+ ok ? [] : [`${at}: must be ${what}`];
129
+
130
+ const isString: Check = (v, at) => must(typeof v === "string", at, "a string");
131
+ const isNumber: Check = (v, at) =>
132
+ must(typeof v === "number" && Number.isFinite(v), at, "a number");
133
+ const isCount: Check = (v, at) =>
134
+ must(Number.isInteger(v) && (v as number) >= 0, at, "a non-negative integer");
135
+ const isBoolean: Check = (v, at) =>
136
+ must(typeof v === "boolean", at, "true or false");
137
+ const isAny: Check = () => [];
138
+ const isSchema: Check = (v, at) => schemaProblems(v, at);
139
+ const isSchemaOrBoolean: Check = (v, at) =>
140
+ typeof v === "boolean" ? [] : schemaProblems(v, at);
141
+ const isStringList: Check = (v, at) =>
142
+ must(
143
+ Array.isArray(v) && v.every((x) => typeof x === "string"),
144
+ at,
145
+ "a list of strings",
146
+ );
147
+ const isSchemaList: Check = (v, at) =>
148
+ Array.isArray(v) && v.length > 0
149
+ ? v.flatMap((x, i) => schemaProblems(x, `${at}/${i}`))
150
+ : [`${at}: must be a non-empty list of schemas`];
151
+ const isSchemaMap: Check = (v, at) =>
152
+ isObject(v)
153
+ ? Object.entries(v).flatMap(([k, x]) => schemaProblems(x, `${at}/${k}`))
154
+ : [`${at}: must be an object`];
155
+
156
+ const isType: Check = (v, at) => {
157
+ const names = Array.isArray(v) ? v : [v];
158
+ if (names.length === 0) return [`${at}: must name at least one type`];
159
+ return names.flatMap((name) =>
160
+ typeof name === "string" && JSON_TYPES.includes(name)
161
+ ? []
162
+ : [
163
+ `${at}: ${JSON.stringify(name)} is not a JSON Schema type (use ${JSON_TYPES.join(", ")})`,
164
+ ],
165
+ );
166
+ };
167
+
168
+ const isPattern: Check = (v, at) => {
169
+ if (typeof v !== "string") return [`${at}: must be a string`];
170
+ try {
171
+ new RegExp(v, "u");
172
+ return [];
173
+ } catch (e) {
174
+ return [
175
+ `${at}: is not a valid regular expression (${e instanceof Error ? e.message : e})`,
176
+ ];
177
+ }
178
+ };
179
+
180
+ /** The JSON Schema keywords accepted, each with a check of its value. Anything else is reported as unknown. */
181
+ const KEYWORDS: Record<string, Check> = {
182
+ // Identity and annotations
183
+ $schema: isString,
184
+ $id: isString,
185
+ $ref: isString,
186
+ $comment: isString,
187
+ $defs: isSchemaMap,
188
+ definitions: isSchemaMap,
189
+ title: isString,
190
+ description: isString,
191
+ default: isAny,
192
+ examples: (v, at) => must(Array.isArray(v), at, "a list"),
193
+ deprecated: isBoolean,
194
+ readOnly: isBoolean,
195
+ writeOnly: isBoolean,
196
+ // Any type
197
+ type: isType,
198
+ enum: (v, at) =>
199
+ must(Array.isArray(v) && v.length > 0, at, "a non-empty list"),
200
+ const: isAny,
201
+ allOf: isSchemaList,
202
+ anyOf: isSchemaList,
203
+ oneOf: isSchemaList,
204
+ not: isSchema,
205
+ if: isSchema,
206
+ then: isSchema,
207
+ else: isSchema,
208
+ // Objects
209
+ properties: isSchemaMap,
210
+ patternProperties: isSchemaMap,
211
+ additionalProperties: isSchemaOrBoolean,
212
+ unevaluatedProperties: isSchemaOrBoolean,
213
+ propertyNames: isSchema,
214
+ required: isStringList,
215
+ minProperties: isCount,
216
+ maxProperties: isCount,
217
+ dependentRequired: (v, at) =>
218
+ isObject(v)
219
+ ? Object.entries(v).flatMap(([k, x]) => isStringList(x, `${at}/${k}`))
220
+ : [`${at}: must be an object`],
221
+ dependentSchemas: isSchemaMap,
222
+ // Arrays
223
+ items: (v, at) =>
224
+ Array.isArray(v) ? isSchemaList(v, at) : isSchemaOrBoolean(v, at),
225
+ prefixItems: isSchemaList,
226
+ additionalItems: isSchemaOrBoolean,
227
+ unevaluatedItems: isSchemaOrBoolean,
228
+ contains: isSchema,
229
+ minContains: isCount,
230
+ maxContains: isCount,
231
+ minItems: isCount,
232
+ maxItems: isCount,
233
+ uniqueItems: isBoolean,
234
+ // Strings
235
+ minLength: isCount,
236
+ maxLength: isCount,
237
+ pattern: isPattern,
238
+ format: isString,
239
+ contentEncoding: isString,
240
+ contentMediaType: isString,
241
+ // Numbers
242
+ minimum: isNumber,
243
+ maximum: isNumber,
244
+ exclusiveMinimum: isNumber,
245
+ exclusiveMaximum: isNumber,
246
+ multipleOf: (v, at) =>
247
+ must(typeof v === "number" && v > 0, at, "a positive number"),
248
+ };
249
+
250
+ /**
251
+ * Checks that `schema` is well-formed JSON Schema, as "/path: problem" lines; empty when it is.
252
+ *
253
+ * Validators silently ignore what they don't understand, so a typo like `"type": "strin"` or `"requried"` would
254
+ * otherwise turn a rule into no rule at all. This catches those before a subagent is started: unknown keywords,
255
+ * unknown type names, and keyword values of the wrong shape, recursively.
256
+ */
257
+ export function schemaProblems(schema: unknown, at = ""): string[] {
258
+ if (!isObject(schema)) return [`${at || "/"}: a schema must be an object`];
259
+ return Object.entries(schema).flatMap(([keyword, value]) => {
260
+ const check = KEYWORDS[keyword];
261
+ const here = `${at}/${keyword}`;
262
+ return check
263
+ ? check(value, here)
264
+ : [`${here}: unknown JSON Schema keyword`];
265
+ });
266
+ }
267
+
268
+ /** Lists how `value` breaks `schema`, as "/path: message" lines (at most 10); empty when it conforms. */
269
+ export function schemaErrors(schema: TSchema, value: unknown): string[] {
270
+ return [...Value.Errors(schema, value)].slice(0, 10).map((e) => {
271
+ // TypeBox reports a property that `additionalProperties: false` forbids as "schema is false".
272
+ const message =
273
+ e.message === "schema is false"
274
+ ? "property not allowed by the schema"
275
+ : e.message;
276
+ return `${e.instancePath || "/"}: ${message}`;
277
+ });
278
+ }