@ckeditor/ckeditor5-dev-agents 0.0.1 → 62.4.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/dist/cli.js ADDED
@@ -0,0 +1,2186 @@
1
+ import { parseArgs, promisify } from "node:util";
2
+ import upath from "upath";
3
+ import { existsSync, globSync } from "node:fs";
4
+ import { pathToFileURL } from "node:url";
5
+ import { cp, glob, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
6
+ import { simpleGit } from "simple-git";
7
+ import { Octokit } from "@octokit/rest";
8
+ import { tmpdir } from "node:os";
9
+ import { ModelRuntime, SessionManager, SettingsManager, createAgentSession, createExtensionRuntime, defineTool, loadSkillsFromDir } from "@earendil-works/pi-coding-agent";
10
+ import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
11
+ import { parse, stringify } from "yaml";
12
+ import { execFile, spawn } from "node:child_process";
13
+ import { hash } from "node:crypto";
14
+ import { decodeHTMLStrict } from "entities";
15
+ import { posix } from "node:path";
16
+ //#region src/constants.ts
17
+ /**
18
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
19
+ * For licensing, see LICENSE.md.
20
+ */
21
+ /**
22
+ * The directory, relative to the target root, that holds the state of every task.
23
+ */
24
+ const STATE_DIRECTORY = ".ai-tasks";
25
+ /**
26
+ * The prefix of report branches: `ai-tasks/<task>/<target>/<branch>`.
27
+ */
28
+ const REPORT_BRANCH_PREFIX = "ai-tasks";
29
+ /**
30
+ * The tools an agent may use while judging. None of them can change a file.
31
+ */
32
+ const READ_ONLY_TOOLS = [
33
+ "read",
34
+ "grep",
35
+ "find",
36
+ "ls"
37
+ ];
38
+ /**
39
+ * Every built-in tool of the Pi agent. The agent ignores unknown tool names, so the configuration is checked against it.
40
+ */
41
+ const AGENT_TOOLS = [
42
+ "read",
43
+ "bash",
44
+ "powershell",
45
+ "edit",
46
+ "write",
47
+ "grep",
48
+ "find",
49
+ "ls"
50
+ ];
51
+ //#endregion
52
+ //#region src/config/importmodules.ts
53
+ /**
54
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
55
+ * For licensing, see LICENSE.md.
56
+ */
57
+ /**
58
+ * Imports the default export of every file matching the pattern in the directory, sorted by path.
59
+ */
60
+ async function importDefaultExports(directory, pattern) {
61
+ const files = globSync(pattern, { cwd: directory });
62
+ const modules = [];
63
+ for (const file of files.map((file) => upath.normalize(file)).sort()) {
64
+ const { default: value } = await import(pathToFileURL(upath.join(directory, file)).href);
65
+ modules.push({
66
+ file,
67
+ value
68
+ });
69
+ }
70
+ return modules;
71
+ }
72
+ //#endregion
73
+ //#region src/config/loadtasks.ts
74
+ /**
75
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
76
+ * For licensing, see LICENSE.md.
77
+ */
78
+ /**
79
+ * Loads every task from `<tasksPath>/<id>/index.{js,mjs,ts,mts}`. Adding a task means adding a directory.
80
+ */
81
+ async function loadTasks(tasksPath) {
82
+ const tasks = /* @__PURE__ */ new Map();
83
+ for (const { file, value } of await importDefaultExports(tasksPath, "*/index.{js,mjs,ts,mts}")) {
84
+ const id = upath.dirname(file);
85
+ if (tasks.has(id)) throw new Error(`The "${id}" task has more than one \`index\` file.`);
86
+ validateTask(value, id);
87
+ tasks.set(id, {
88
+ task: value,
89
+ directory: upath.join(tasksPath, id)
90
+ });
91
+ }
92
+ return tasks;
93
+ }
94
+ /**
95
+ * Checks the rules of a task that its type cannot express.
96
+ */
97
+ function validateTask(task, id) {
98
+ const invalid = (message) => /* @__PURE__ */ new Error(`The "${id}" task is invalid: ${message}`);
99
+ if (task?.id !== id) throw invalid(`the module must export by default a task whose \`id\` matches the directory name ("${id}").`);
100
+ if (!Array.isArray(task.ruleIds)) throw invalid("`ruleIds` must be an array of rule IDs.");
101
+ if (task.ruleIds.some((ruleId) => typeof ruleId !== "string" || ruleId === "" || ruleId.includes("|"))) throw invalid("every rule ID must be a non-empty string without \"|\".");
102
+ if (new Set(task.ruleIds).size !== task.ruleIds.length) throw invalid("`ruleIds` must be unique.");
103
+ if (!task.scope && !task.include?.length) throw invalid("it needs `scope()`, or `include` to work on the matching files.");
104
+ if (task.writes?.length && !task.fix) throw invalid("a task with `writes` must define `fix()`.");
105
+ if (task.fix && !task.writes?.length) throw invalid("a task with `fix()` must define `writes`: the files the fix may change.");
106
+ if (task.verify && !task.fix) throw invalid("`verify()` checks a fix, so it requires `fix()`.");
107
+ const unmatchablePatterns = (task.writes ?? []).filter((pattern) => /^\.{0,2}\//.test(pattern));
108
+ if (unmatchablePatterns.length) throw invalid(`\`writes\` must be globs relative to the target root, without a leading "./", "../" or "/". Fix: ${unmatchablePatterns.join(", ")}.`);
109
+ for (const field of ["maxUnits", "concurrency"]) {
110
+ const value = task[field];
111
+ if (value !== void 0 && !(Number.isInteger(value) && value >= 1)) throw invalid(`\`${field}\` must be an integer of 1 or more.`);
112
+ }
113
+ const unknownTools = [...task.agent?.judgeTools ?? [], ...task.agent?.fixTools ?? []].filter((tool) => !AGENT_TOOLS.includes(tool));
114
+ if (unknownTools.length) throw invalid(`the agent has no such tools: ${[...new Set(unknownTools)].join(", ")}. The available tools are: ${AGENT_TOOLS.join(", ")}.`);
115
+ const writingTools = (task.agent?.judgeTools ?? []).filter((tool) => !READ_ONLY_TOOLS.includes(tool));
116
+ if (writingTools.length) throw invalid(`\`agent.judgeTools\` may contain only read-only tools (${READ_ONLY_TOOLS.join(", ")}). Remove: ${writingTools.join(", ")}.`);
117
+ }
118
+ //#endregion
119
+ //#region src/utils/files.ts
120
+ /**
121
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
122
+ * For licensing, see LICENSE.md.
123
+ */
124
+ /**
125
+ * Runs the read and returns `null` instead of throwing when the file does not exist.
126
+ */
127
+ async function readIfExists(read) {
128
+ try {
129
+ return await read();
130
+ } catch (error) {
131
+ if (error.code === "ENOENT") return null;
132
+ throw error;
133
+ }
134
+ }
135
+ /**
136
+ * Whether a normalized relative path points outside of the directory it is relative to.
137
+ */
138
+ function isOutside(relativePath) {
139
+ return relativePath === ".." || relativePath.startsWith("../") || upath.isAbsolute(relativePath);
140
+ }
141
+ //#endregion
142
+ //#region src/utils/strings.ts
143
+ /**
144
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
145
+ * For licensing, see LICENSE.md.
146
+ */
147
+ /**
148
+ * Compares strings by code units, independently of the locale, so sorted state files are the same on every machine.
149
+ */
150
+ function compare(a, b) {
151
+ if (a === b) return 0;
152
+ return a < b ? -1 : 1;
153
+ }
154
+ /**
155
+ * Joins the lines of a text into one line, for example to show a multi-line error in a list.
156
+ */
157
+ function inline(text) {
158
+ return text.replace(/\s*\n\s*/g, " ").trim();
159
+ }
160
+ /**
161
+ * Returns the message of a thrown value. Hooks of a task may throw anything, for example a string.
162
+ */
163
+ function getErrorMessage(error) {
164
+ return error instanceof Error ? error.message : String(error);
165
+ }
166
+ function plural(count, word) {
167
+ return count === 1 ? word : `${word}s`;
168
+ }
169
+ //#endregion
170
+ //#region src/config/loadtargets.ts
171
+ /**
172
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
173
+ * For licensing, see LICENSE.md.
174
+ */
175
+ const DEFAULT_MAX_UNITS = 100;
176
+ /**
177
+ * Loads every target from `<targetsPath>/<name>.{js,mjs,ts,mts}`. Adding a repository means adding a file.
178
+ */
179
+ async function loadTargets(targetsPath, tasks) {
180
+ const targets = /* @__PURE__ */ new Map();
181
+ for (const { file, value } of await importDefaultExports(targetsPath, "*.{js,mjs,ts,mts}")) {
182
+ const name = file.replace(/\.(js|mjs|ts|mts)$/, "");
183
+ if (targets.has(name)) throw new Error(`The "${name}" target is defined in more than one file.`);
184
+ targets.set(name, normalizeTarget(value, {
185
+ name,
186
+ tasks
187
+ }));
188
+ }
189
+ assertSeparateStates([...targets.values()]);
190
+ return targets;
191
+ }
192
+ function normalizeTarget(config, { name, tasks }) {
193
+ const invalid = (message) => /* @__PURE__ */ new Error(`The "${name}" target is invalid: ${message}`);
194
+ if (!config || !/^[\w.-]+\/[\w.-]+$/.test(config.slug)) throw invalid("the module must export by default a target with a `slug` like \"owner/repository\".");
195
+ const root = upath.normalize(config.root ?? ".");
196
+ if (isOutside(root)) throw invalid("`root` must be a path inside the repository.");
197
+ if (typeof config.tasks !== "object" || config.tasks === null || Array.isArray(config.tasks)) throw invalid("`tasks` must be an object that maps task names to their settings.");
198
+ const instances = /* @__PURE__ */ new Map();
199
+ for (const [instanceId, entry] of Object.entries(config.tasks)) {
200
+ if (instanceId === "" || /[|/\\]/.test(instanceId)) throw invalid(`the "${instanceId}" task name may not be empty or contain "|", "/" or "\\".`);
201
+ const loaded = tasks.get(entry.task ?? instanceId);
202
+ if (!loaded) throw invalid(`the "${entry.task ?? instanceId}" task does not exist.`);
203
+ const instance = resolveInstance(instanceId, entry, loaded);
204
+ try {
205
+ validateTask(instance.task, instanceId);
206
+ } catch (error) {
207
+ throw invalid(getErrorMessage(error));
208
+ }
209
+ instances.set(instanceId, instance);
210
+ }
211
+ return {
212
+ name,
213
+ slug: config.slug,
214
+ root,
215
+ tasks: instances
216
+ };
217
+ }
218
+ /**
219
+ * Throws when two targets would keep the state of a task in the same directory of the same repository. Each run would
220
+ * then drop the baseline and the findings of the other target.
221
+ */
222
+ function assertSeparateStates(targets) {
223
+ const owners = /* @__PURE__ */ new Map();
224
+ for (const target of targets) for (const instanceId of target.tasks.keys()) {
225
+ const statePath = upath.join(target.root, STATE_DIRECTORY, instanceId);
226
+ const key = `${target.slug}:${statePath}`;
227
+ const owner = owners.get(key);
228
+ if (owner) throw new Error(`The "${owner}" and "${target.name}" targets keep the state of the "${instanceId}" task in the same directory of ${target.slug}: "${statePath}". Give them different roots, or name the task differently in one of them.`);
229
+ owners.set(key, target.name);
230
+ }
231
+ }
232
+ /**
233
+ * Puts the task under the name of the instance and applies the overrides of the target. An instance that runs a task
234
+ * under another name says so in its title, so its report pull request can be told apart from the one of the task.
235
+ */
236
+ function resolveInstance(instanceId, entry, { task, directory }) {
237
+ return {
238
+ task: {
239
+ ...task,
240
+ id: instanceId,
241
+ title: instanceId === task.id ? task.title : `${task.title} (${instanceId})`,
242
+ include: entry.include ?? task.include ?? [],
243
+ exclude: entry.exclude ?? task.exclude ?? [],
244
+ maxUnits: entry.maxUnits ?? task.maxUnits ?? DEFAULT_MAX_UNITS,
245
+ options: {
246
+ ...task.defaultOptions,
247
+ ...entry.options
248
+ }
249
+ },
250
+ directory,
251
+ enabled: entry.enabled !== false
252
+ };
253
+ }
254
+ //#endregion
255
+ //#region src/config/loadconfig.ts
256
+ /**
257
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
258
+ * For licensing, see LICENSE.md.
259
+ */
260
+ /**
261
+ * Loads the targets of the config directory, with their tasks. A directory without tasks or targets is almost always
262
+ * the wrong one, so it throws.
263
+ */
264
+ async function loadConfig(configPath) {
265
+ const tasks = await loadTasks(upath.join(configPath, "tasks"));
266
+ const targets = await loadTargets(upath.join(configPath, "targets"), tasks);
267
+ for (const [name, items] of [["tasks", tasks], ["targets", targets]]) if (!items.size) throw new Error(`No ${name} found in "${upath.join(configPath, name)}". Check that "--config" points to the directory with "tasks/" and "targets/".`);
268
+ return targets;
269
+ }
270
+ //#endregion
271
+ //#region src/config/selecttargets.ts
272
+ /**
273
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
274
+ * For licensing, see LICENSE.md.
275
+ */
276
+ /**
277
+ * Returns the targets to run in a checkout of the `originSlug` repository: the named ones, or every target of
278
+ * the repository. A checkout belongs to one repository, so only the targets of that repository can run in it.
279
+ */
280
+ function selectTargets(targets, { originSlug, names, taskIds }) {
281
+ for (const name of names) if (!targets.has(name)) throw new Error(`The "${name}" target does not exist. Available targets: ${[...targets.keys()].join(", ")}.`);
282
+ const origin = originSlug ?? "an unknown repository";
283
+ const selected = names.length ? names.map((name) => targets.get(name)) : [...targets.values()].filter((target) => target.slug === originSlug);
284
+ for (const target of selected) if (target.slug !== originSlug) throw new Error(`The "${target.name}" target is for "${target.slug}", but the checkout is a clone of ${origin}.`);
285
+ if (!selected.length) throw new Error(`No target is configured for the checkout, which is a clone of ${origin}.`);
286
+ for (const taskId of taskIds) if (!selected.some((target) => target.tasks.has(taskId))) throw new Error(`The "${taskId}" task is not configured in any of the selected targets.`);
287
+ return selected;
288
+ }
289
+ //#endregion
290
+ //#region src/git/gitworkspace.ts
291
+ /**
292
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
293
+ * For licensing, see LICENSE.md.
294
+ */
295
+ /**
296
+ * Opens the checkout that contains `cwd`. The checkout is prepared by whoever runs the framework: cloned, with
297
+ * the dependencies installed, and with the base branch checked out. The `author` is needed only to commit.
298
+ */
299
+ async function openWorkspace({ cwd, author }) {
300
+ const gitOptions = { config: [...author ? [`user.name=${author.name}`, `user.email=${author.email}`] : [], "commit.gpgsign=false"] };
301
+ let topLevel;
302
+ try {
303
+ topLevel = (await simpleGit({ baseDir: cwd }).revparse(["--show-toplevel"])).trim();
304
+ } catch (error) {
305
+ throw new Error(`"${cwd}" is not inside a git repository.`, { cause: error });
306
+ }
307
+ const path = upath.normalize(await realpath(topLevel));
308
+ const git = simpleGit({
309
+ ...gitOptions,
310
+ baseDir: path
311
+ });
312
+ const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
313
+ const baseCommit = (await git.revparse(["HEAD"])).trim();
314
+ let reportBranch;
315
+ let needsForcePush = false;
316
+ return {
317
+ path,
318
+ branch,
319
+ originSlug: await getOriginSlug(git),
320
+ async assertPublishable(paths) {
321
+ if (branch === "HEAD") throw new Error("Publishing needs a checked-out branch, but HEAD is detached. Check out the base branch first.");
322
+ if ((await git.raw(["rev-parse", "--is-shallow-repository"])).trim() === "true") throw new Error("Publishing needs the full history of the checkout, but it is shallow. Configure the checkout in CI to fetch the whole history.");
323
+ if ((await git.raw(["status", "--porcelain"])).trim()) throw new Error("Publishing needs a clean working tree, but it has uncommitted changes. Commit or remove them first.");
324
+ const ignored = paths.length ? (await git.raw([
325
+ "check-ignore",
326
+ "--",
327
+ ...paths
328
+ ])).trim() : "";
329
+ if (ignored) throw new Error(`Publishing commits the state of the tasks, but a \`.gitignore\` file ignores it: ${ignored.split("\n").join(", ")}. Stop ignoring these paths first.`);
330
+ },
331
+ async checkoutReportBranch({ name, continueExisting }) {
332
+ const existsRemotely = Boolean((await git.raw([
333
+ "ls-remote",
334
+ "--heads",
335
+ "origin",
336
+ name
337
+ ])).trim());
338
+ reportBranch = name;
339
+ if (!continueExisting || !existsRemotely) {
340
+ needsForcePush = existsRemotely;
341
+ await git.checkout([
342
+ "-B",
343
+ name,
344
+ baseCommit
345
+ ]);
346
+ return;
347
+ }
348
+ needsForcePush = false;
349
+ await git.fetch("origin", `+refs/heads/${name}:refs/remotes/origin/${name}`);
350
+ await git.checkout([
351
+ "-B",
352
+ name,
353
+ `origin/${name}`
354
+ ]);
355
+ try {
356
+ await git.merge([
357
+ "--no-edit",
358
+ "-m",
359
+ `Merge ${branch} into ${name}.`,
360
+ baseCommit
361
+ ]);
362
+ } catch (error) {
363
+ await git.merge(["--abort"]).catch(() => {});
364
+ throw new Error(`Merging "${branch}" into "${name}" failed. Resolve the conflict in the report pull request by hand. Details: ${error.message}`, { cause: error });
365
+ }
366
+ },
367
+ async commit(paths, message) {
368
+ await git.add([
369
+ "--all",
370
+ "--",
371
+ ...paths
372
+ ]);
373
+ if (!(await git.diff([
374
+ "--cached",
375
+ "--name-only",
376
+ "--",
377
+ ...paths
378
+ ])).trim()) return false;
379
+ await git.raw([
380
+ "commit",
381
+ "--quiet",
382
+ "-m",
383
+ message,
384
+ "--",
385
+ ...paths
386
+ ]);
387
+ return true;
388
+ },
389
+ async push() {
390
+ await git.push("origin", reportBranch, needsForcePush ? ["--force"] : []);
391
+ needsForcePush = false;
392
+ },
393
+ async returnToBase() {
394
+ await git.raw([
395
+ "reset",
396
+ "--hard",
397
+ "--quiet"
398
+ ]);
399
+ await git.raw([
400
+ "clean",
401
+ "-fd",
402
+ "--quiet"
403
+ ]);
404
+ await git.checkout(branch);
405
+ },
406
+ async refExists(ref) {
407
+ return succeeds(() => git.raw([
408
+ "rev-parse",
409
+ "--verify",
410
+ `${ref}^{commit}`
411
+ ]));
412
+ },
413
+ async hasChanges(paths) {
414
+ return Boolean((await git.raw([
415
+ "status",
416
+ "--porcelain",
417
+ "--",
418
+ ...paths
419
+ ])).trim());
420
+ },
421
+ async restore(paths, ref) {
422
+ for (const file of paths) if (await succeeds(() => git.raw([
423
+ "cat-file",
424
+ "-e",
425
+ `${ref}:${file}`
426
+ ]))) await git.raw([
427
+ "checkout",
428
+ ref,
429
+ "--",
430
+ file
431
+ ]);
432
+ else {
433
+ await git.raw([
434
+ "rm",
435
+ "--cached",
436
+ "--quiet",
437
+ "--ignore-unmatch",
438
+ "--",
439
+ file
440
+ ]);
441
+ await rm(upath.join(path, file), { force: true });
442
+ }
443
+ }
444
+ };
445
+ }
446
+ /**
447
+ * Returns the GitHub slug of `origin`. Supports the SSH (`git@github.com:owner/repo.git`) and the HTTPS
448
+ * (`https://github.com/owner/repo`) forms.
449
+ */
450
+ async function getOriginSlug(git) {
451
+ return ((await git.getRemotes(true)).find((remote) => remote.name === "origin")?.refs.fetch ?? "").match(/github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/)?.[1];
452
+ }
453
+ async function succeeds(command) {
454
+ try {
455
+ await command();
456
+ return true;
457
+ } catch {
458
+ return false;
459
+ }
460
+ }
461
+ //#endregion
462
+ //#region src/github/githubclient.ts
463
+ /**
464
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
465
+ * For licensing, see LICENSE.md.
466
+ */
467
+ const API_VERSION = "2026-03-10";
468
+ const REQUEST_TIMEOUT = 3e4;
469
+ /**
470
+ * Creates a minimal client of the GitHub REST API with the calls that report pull requests need.
471
+ */
472
+ function createGitHubClient(token) {
473
+ const octokit = new Octokit({ auth: token });
474
+ octokit.hook.before("request", (options) => {
475
+ options.headers["x-github-api-version"] = API_VERSION;
476
+ options.request = {
477
+ ...options.request,
478
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT)
479
+ };
480
+ });
481
+ octokit.hook.error("request", (error) => {
482
+ const { status, request } = error;
483
+ if (!status || !request) throw error;
484
+ const message = `GitHub API request "${request.method} ${request.url}" failed with status ${status}: ${error.message}`;
485
+ throw new Error(message, { cause: error });
486
+ });
487
+ return {
488
+ async findOpenPullRequest({ slug, head }) {
489
+ const [owner, repo] = splitSlug(slug);
490
+ const { data } = await octokit.pulls.list({
491
+ owner,
492
+ repo,
493
+ state: "open",
494
+ head: `${owner}:${head}`
495
+ });
496
+ return data[0] ?? null;
497
+ },
498
+ async createPullRequest({ slug, head, base, title, body }) {
499
+ const [owner, repo] = splitSlug(slug);
500
+ return (await octokit.pulls.create({
501
+ owner,
502
+ repo,
503
+ head,
504
+ base,
505
+ title,
506
+ body
507
+ })).data;
508
+ },
509
+ async updatePullRequest({ slug, number, body }) {
510
+ const [owner, repo] = splitSlug(slug);
511
+ return (await octokit.pulls.update({
512
+ owner,
513
+ repo,
514
+ pull_number: number,
515
+ body
516
+ })).data;
517
+ },
518
+ async createComment({ slug, number, body }) {
519
+ const [owner, repo] = splitSlug(slug);
520
+ return (await octokit.issues.createComment({
521
+ owner,
522
+ repo,
523
+ issue_number: number,
524
+ body
525
+ })).data;
526
+ }
527
+ };
528
+ }
529
+ function splitSlug(slug) {
530
+ const [owner, repo] = slug.split("/");
531
+ return [owner, repo];
532
+ }
533
+ //#endregion
534
+ //#region src/utils/githuburls.ts
535
+ /**
536
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
537
+ * For licensing, see LICENSE.md.
538
+ */
539
+ function getBlobUrl(target, branch, path) {
540
+ return `https://github.com/${target.slug}/blob/${encodePath(branch)}/${encodePath(upath.join(target.root, path))}`;
541
+ }
542
+ function getHistoryUrl(target, branch, path) {
543
+ return `https://github.com/${target.slug}/commits/${encodePath(branch)}/${encodePath(upath.join(target.root, path))}`;
544
+ }
545
+ function encodePath(path) {
546
+ return path.split("/").map(encodeURIComponent).join("/");
547
+ }
548
+ //#endregion
549
+ //#region src/report/renderpullrequest.ts
550
+ /**
551
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
552
+ * For licensing, see LICENSE.md.
553
+ */
554
+ /**
555
+ * Renders the description of the report pull request. It is rewritten on every run that pushes,
556
+ * so it always shows the current number of findings.
557
+ */
558
+ function renderPullRequestBody({ task, target, branch, reportBranch, open, fixed }) {
559
+ const reportUrl = getBlobUrl(target, reportBranch, `${STATE_DIRECTORY}/${task.id}/report.md`);
560
+ return [
561
+ `This pull request is maintained by the "${task.title}" task (\`${task.id}\`) of \`@ckeditor/ckeditor5-dev-agents\`. It covers \`${target.name}\` at \`${branch}\`. Every run pushes new commits here and comments with what it found.`,
562
+ "",
563
+ `**${open}** open ${plural(open, "finding")}, **${fixed}** fixed in this pull request. See [report.md](${reportUrl}) for the findings and how to review them.`,
564
+ "",
565
+ `Merge this pull request to record the state on \`${branch}\`. Until then, the next runs continue this branch, so no unit is judged twice.`,
566
+ ""
567
+ ].join("\n");
568
+ }
569
+ /**
570
+ * Renders the comment posted after every run that pushed something.
571
+ */
572
+ function renderRunComment({ today, summary, buildUrl }) {
573
+ return [
574
+ buildUrl ? `### [Run](${buildUrl}) on ${today}` : `### Run on ${today}`,
575
+ "",
576
+ ...renderSummary(summary)
577
+ ].join("\n");
578
+ }
579
+ function renderSummary(summary) {
580
+ const lines = [
581
+ "| Units | Changed | Judged | Left | New findings | Already open | Rejected | Fixed | Discarded | Files changed | Errors | Open | Cost |",
582
+ "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
583
+ "| " + [
584
+ summary.units,
585
+ summary.changed,
586
+ summary.judged,
587
+ summary.deferred,
588
+ summary.newFindings,
589
+ summary.alreadyOpen,
590
+ summary.rejected,
591
+ summary.fixed,
592
+ summary.discarded,
593
+ summary.changedFiles,
594
+ summary.errors,
595
+ summary.open,
596
+ `$${summary.cost.toFixed(2)}`
597
+ ].join(" | ") + " |",
598
+ ""
599
+ ];
600
+ const problems = summary.problems.map(({ unit, message }) => `* ${unit ? `\`${unit}\`: ` : ""}${inline(message)}`);
601
+ if (problems.length) lines.push("#### Problems", "", ...problems, "");
602
+ return lines;
603
+ }
604
+ //#endregion
605
+ //#region src/agent/createagent.ts
606
+ /**
607
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
608
+ * For licensing, see LICENSE.md.
609
+ */
610
+ const RESULT_TOOL_NAME = "submit_result";
611
+ const AGENT_DIRECTORY = upath.join(tmpdir(), "ckeditor5-dev-agents");
612
+ const DEFAULT_TIMEOUT = 1800;
613
+ const MAX_RETRIES = 3;
614
+ const MAX_ARGUMENTS_LENGTH = 120;
615
+ const modelRuntimes = /* @__PURE__ */ new Map();
616
+ /**
617
+ * Creates the `agent` of one phase of a task. Every `run()` starts a new in-memory Pi session that is configured only
618
+ * by the task: its instructions, skills, tools and model. The API key comes from the task config (`agent.apiKey`),
619
+ * never from the environment of the process.
620
+ */
621
+ function createAgent(options) {
622
+ const { taskId, config, taskDirectory, cwd, tools, usage, log, warn } = options;
623
+ let resources;
624
+ const loadResources = async (agentConfig) => createResourceLoader({
625
+ systemPrompt: await getInstructions(agentConfig, taskDirectory),
626
+ skills: getSkills(agentConfig, taskDirectory, (message) => {
627
+ log(`[agent] ${message}`);
628
+ warn(message);
629
+ })
630
+ });
631
+ return { async run({ prompt, outputSchema }) {
632
+ if (!config) throw new Error(`The "${taskId}" task calls \`agent.run()\`, but it does not configure \`agent\`.`);
633
+ if (!config.apiKey) throw new Error(`The "${taskId}" task calls \`agent.run()\`, but \`agent.apiKey\` is empty. Provide the API key of the "${config.model.provider}" provider in the task config.`);
634
+ const modelRuntime = await getModelRuntime(config.model.provider, config.apiKey);
635
+ const model = modelRuntime.getModel(config.model.provider, config.model.id);
636
+ if (!model) throw new Error(`The "${config.model.provider}/${config.model.id}" model is not available.`);
637
+ let result;
638
+ const resultTool = outputSchema && defineTool({
639
+ name: RESULT_TOOL_NAME,
640
+ label: "Submit result",
641
+ description: "Submits the result of the task. Call it exactly once, when you are done.",
642
+ parameters: outputSchema,
643
+ async execute(_toolCallId, params) {
644
+ result = params;
645
+ return {
646
+ content: [{
647
+ type: "text",
648
+ text: "The result was received. You are done."
649
+ }],
650
+ details: void 0
651
+ };
652
+ }
653
+ });
654
+ const { session } = await createAgentSession({
655
+ cwd,
656
+ agentDir: AGENT_DIRECTORY,
657
+ model,
658
+ modelRuntime,
659
+ thinkingLevel: config.thinkingLevel,
660
+ tools: resultTool ? [...tools, RESULT_TOOL_NAME] : [...tools],
661
+ customTools: resultTool ? [resultTool] : [],
662
+ sessionManager: SessionManager.inMemory(cwd),
663
+ settingsManager: SettingsManager.inMemory({
664
+ enableInstallTelemetry: false,
665
+ retry: {
666
+ enabled: true,
667
+ maxRetries: MAX_RETRIES
668
+ }
669
+ }),
670
+ resourceLoader: await (resources ??= loadResources(config))
671
+ });
672
+ const unsubscribe = session.subscribe((event) => {
673
+ if (event.type === "tool_execution_start" && event.toolName !== RESULT_TOOL_NAME) log(`[agent] ${event.toolName} ${summarizeArguments(event.args)}`);
674
+ });
675
+ const timeout = config.timeout ?? DEFAULT_TIMEOUT;
676
+ let hasTimedOut = false;
677
+ const timer = setTimeout(() => {
678
+ hasTimedOut = true;
679
+ session.abort().catch(() => {});
680
+ }, timeout * 1e3);
681
+ const sendPrompt = async (text) => {
682
+ try {
683
+ await session.prompt(text);
684
+ } catch (error) {
685
+ if (!hasTimedOut) throw error;
686
+ }
687
+ if (hasTimedOut) throw new Error(`The agent did not finish within ${timeout} seconds.`);
688
+ };
689
+ try {
690
+ await sendPrompt(resultTool ? `${prompt}\n\nWhen you are done, call the \`${RESULT_TOOL_NAME}\` tool exactly once with the result. Do not write the result as text.` : prompt);
691
+ if (resultTool && result === void 0) await sendPrompt(`You have not called the \`${RESULT_TOOL_NAME}\` tool. Call it now with the result.`);
692
+ const lastMessage = session.messages.findLast((message) => message.role === "assistant");
693
+ if (lastMessage?.stopReason === "error") throw new Error(`The agent failed: ${lastMessage.errorMessage ?? "unknown error"}`);
694
+ if (resultTool && result === void 0) throw new Error(`The agent did not return a result through the \`${RESULT_TOOL_NAME}\` tool.`);
695
+ return {
696
+ output: result,
697
+ text: session.getLastAssistantText() ?? ""
698
+ };
699
+ } finally {
700
+ const stats = session.getSessionStats();
701
+ usage.cost += stats.cost;
702
+ usage.tokens += stats.tokens.total;
703
+ clearTimeout(timer);
704
+ unsubscribe();
705
+ session.dispose();
706
+ }
707
+ } };
708
+ }
709
+ function getModelRuntime(provider, apiKey) {
710
+ const key = `${provider}\0${apiKey}`;
711
+ if (!modelRuntimes.has(key)) modelRuntimes.set(key, createModelRuntime(provider, apiKey));
712
+ return modelRuntimes.get(key);
713
+ }
714
+ async function createModelRuntime(provider, apiKey) {
715
+ const modelRuntime = await ModelRuntime.create({
716
+ credentials: new InMemoryCredentialStore(),
717
+ modelsPath: null,
718
+ allowModelNetwork: false
719
+ });
720
+ await modelRuntime.setRuntimeApiKey(provider, apiKey);
721
+ return modelRuntime;
722
+ }
723
+ async function getInstructions(config, taskDirectory) {
724
+ if (config.instructions) return config.instructions;
725
+ if (config.instructionsPath) return readFile(upath.resolve(taskDirectory, config.instructionsPath), "utf8");
726
+ }
727
+ function getSkills(config, taskDirectory, warn) {
728
+ return (config.skills ?? []).flatMap((directory) => {
729
+ const { skills, diagnostics } = loadSkillsFromDir({
730
+ dir: upath.resolve(taskDirectory, directory),
731
+ source: "task"
732
+ });
733
+ for (const diagnostic of diagnostics) warn(`A skill could not be loaded: ${diagnostic.message}`);
734
+ return skills;
735
+ });
736
+ }
737
+ /**
738
+ * Gives the agent only the system prompt and the skills of the task, so a run behaves the same on every machine,
739
+ * whatever user and project resources (`AGENTS.md`, `~/.pi`, extensions, prompt templates) it has.
740
+ */
741
+ function createResourceLoader({ systemPrompt, skills }) {
742
+ return {
743
+ getExtensions: () => ({
744
+ extensions: [],
745
+ errors: [],
746
+ runtime: createExtensionRuntime()
747
+ }),
748
+ getSkills: () => ({
749
+ skills,
750
+ diagnostics: []
751
+ }),
752
+ getPrompts: () => ({
753
+ prompts: [],
754
+ diagnostics: []
755
+ }),
756
+ getThemes: () => ({
757
+ themes: [],
758
+ diagnostics: []
759
+ }),
760
+ getAgentsFiles: () => ({ agentsFiles: [] }),
761
+ getSystemPrompt: () => systemPrompt,
762
+ getSystemPromptSource: () => void 0,
763
+ getAppendSystemPrompt: () => [],
764
+ getAppendSystemPromptSources: () => [],
765
+ extendResources: () => {},
766
+ reload: async () => {}
767
+ };
768
+ }
769
+ function summarizeArguments(args) {
770
+ const text = String(JSON.stringify(args));
771
+ return text.length > MAX_ARGUMENTS_LENGTH ? `${text.slice(0, 117)}...` : text;
772
+ }
773
+ //#endregion
774
+ //#region src/state/decisions.ts
775
+ /**
776
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
777
+ * For licensing, see LICENSE.md.
778
+ */
779
+ const REQUIRED_FIELDS = [
780
+ "unit",
781
+ "rule",
782
+ "fragment",
783
+ "reason"
784
+ ];
785
+ const OPTIONAL_FIELDS = ["discriminator", "finding"];
786
+ /**
787
+ * Loads every decision file (`*.yml`) from the directory. A file that cannot be used does not stop
788
+ * the run. It is skipped and reported as a warning, so the finding it was meant to reject is reported again.
789
+ */
790
+ async function loadDecisions(decisionsPath) {
791
+ const files = globSync("*.yml", { cwd: decisionsPath });
792
+ const decisions = [];
793
+ const warnings = [];
794
+ for (const file of files.sort()) try {
795
+ const content = await readFile(upath.join(decisionsPath, file), "utf8");
796
+ decisions.push({
797
+ file,
798
+ ...parseDecision(content)
799
+ });
800
+ } catch (error) {
801
+ warnings.push(`The "decisions/${file}" decision file was skipped: ${error.message.split("\n")[0]}`);
802
+ }
803
+ return {
804
+ decisions,
805
+ warnings
806
+ };
807
+ }
808
+ /**
809
+ * Returns the decisions about the same claim as the finding: the same unit, rule and discriminator.
810
+ */
811
+ function findDecisions(decisions, finding) {
812
+ return decisions.filter((decision) => {
813
+ return decision.unit === finding.unit && decision.rule === finding.ruleId && decision.discriminator === finding.discriminator;
814
+ });
815
+ }
816
+ /**
817
+ * Returns the decisions about the unit, each marked as expired or not. The harness hands them to `judge()`,
818
+ * so a model reads the human reasoning, including the reasoning about content that has changed since.
819
+ */
820
+ function getPriorDecisions(decisions, unitKey, fragment) {
821
+ return decisions.filter((decision) => decision.unit === unitKey).map(({ unit, rule, discriminator, fragment: decisionFragment, finding, reason }) => ({
822
+ unit,
823
+ rule,
824
+ discriminator,
825
+ fragment: decisionFragment,
826
+ finding,
827
+ reason,
828
+ expired: decisionFragment !== fragment
829
+ }));
830
+ }
831
+ function parseDecision(content) {
832
+ const decision = parse(content, { schema: "failsafe" });
833
+ if (!decision || typeof decision !== "object" || Array.isArray(decision)) throw new Error("It must contain a single YAML mapping.");
834
+ const fields = decision;
835
+ for (const field of REQUIRED_FIELDS) if (typeof fields[field] !== "string" || fields[field].trim() === "") throw new Error(`The "${field}" field must be a non-empty string.`);
836
+ for (const field of OPTIONAL_FIELDS) if (fields[field] !== void 0 && typeof fields[field] !== "string") throw new Error(`The "${field}" field must be a string.`);
837
+ const text = (field) => (fields[field] ?? "").trim();
838
+ return {
839
+ unit: text("unit"),
840
+ rule: text("rule"),
841
+ discriminator: text("discriminator"),
842
+ fragment: text("fragment"),
843
+ finding: text("finding"),
844
+ reason: text("reason")
845
+ };
846
+ }
847
+ /**
848
+ * Renders the decision file that rejects the finding.
849
+ */
850
+ function renderDecision(finding, reason) {
851
+ return stringify({
852
+ unit: finding.unit,
853
+ rule: finding.ruleId,
854
+ discriminator: finding.discriminator || void 0,
855
+ fragment: finding.fragment,
856
+ finding: finding.detail,
857
+ reason
858
+ }, { lineWidth: 0 });
859
+ }
860
+ /**
861
+ * Returns a readable file name, without the extension, for the decision about the finding, for example
862
+ * `b2-docs-features-tables`.
863
+ */
864
+ function getDecisionFileName({ ruleId, unit, discriminator }) {
865
+ return [
866
+ ruleId,
867
+ unit.replace(/\.md$/, ""),
868
+ discriminator
869
+ ].filter(Boolean).join("-").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
870
+ }
871
+ //#endregion
872
+ //#region src/state/statestore.ts
873
+ /**
874
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
875
+ * For licensing, see LICENSE.md.
876
+ */
877
+ /**
878
+ * Reads the state of one task: `baseline.json`, `open.json` and `decisions/*.yml`. Missing files mean
879
+ * an empty state, which is what the first run of a task sees.
880
+ */
881
+ async function readTaskState(taskStatePath) {
882
+ const baseline = await readJson(taskStatePath, "baseline.json", {});
883
+ const open = await readJson(taskStatePath, "open.json", []);
884
+ const { decisions, warnings } = await loadDecisions(upath.join(taskStatePath, "decisions"));
885
+ if (!baseline || typeof baseline !== "object" || Array.isArray(baseline)) throw new Error("The \"baseline.json\" file must contain an object.");
886
+ if (!Array.isArray(open)) throw new Error("The \"open.json\" file must contain an array.");
887
+ return {
888
+ baseline,
889
+ open,
890
+ decisions,
891
+ warnings
892
+ };
893
+ }
894
+ /**
895
+ * Writes the state of one task. Keys and findings are sorted, so the diff shows only what really changed.
896
+ */
897
+ async function writeTaskState(taskStatePath, { baseline, open, report }) {
898
+ const sortedBaseline = Object.fromEntries(Object.keys(baseline).sort().map((key) => [key, baseline[key]]));
899
+ const sortedOpen = open.toSorted((a, b) => compare(a.fingerprint, b.fingerprint));
900
+ await mkdir(taskStatePath, { recursive: true });
901
+ await writeFile(upath.join(taskStatePath, "baseline.json"), toJson(sortedBaseline));
902
+ await writeFile(upath.join(taskStatePath, "open.json"), toJson(sortedOpen));
903
+ await writeFile(upath.join(taskStatePath, "report.md"), report);
904
+ }
905
+ async function readJson(directory, file, defaultValue) {
906
+ const content = await readIfExists(() => readFile(upath.join(directory, file), "utf8"));
907
+ if (content === null) return defaultValue;
908
+ try {
909
+ return JSON.parse(content);
910
+ } catch (error) {
911
+ throw new Error(`The "${file}" file is not valid JSON: ${error.message}`, { cause: error });
912
+ }
913
+ }
914
+ function toJson(value) {
915
+ return JSON.stringify(value, null, " ") + "\n";
916
+ }
917
+ //#endregion
918
+ //#region src/report/renderreport.ts
919
+ /**
920
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
921
+ * For licensing, see LICENSE.md.
922
+ */
923
+ /**
924
+ * Renders `report.md` of one task from its findings. The file is regenerated on every run,
925
+ * so `open.json` stays the only source of truth.
926
+ */
927
+ function renderReport({ task, target, branch, open }) {
928
+ const taskStatePath = `${STATE_DIRECTORY}/${task.id}`;
929
+ const sorted = open.toSorted((a, b) => compare(a.fingerprint, b.fingerprint));
930
+ const fixed = sorted.filter((finding) => finding.fix);
931
+ const unresolved = sorted.filter((finding) => !finding.fix);
932
+ const lines = [
933
+ "<!-- Generated from open.json by @ckeditor/ckeditor5-dev-agents. Edit open.json instead. -->",
934
+ "",
935
+ `# ${task.title}`,
936
+ ""
937
+ ];
938
+ if (!open.length) {
939
+ lines.push("There are no findings.", "");
940
+ return lines.join("\n");
941
+ }
942
+ lines.push(`**${unresolved.length}** open ${plural(unresolved.length, "finding")}, **${fixed.length}** fixed in the report pull request.`, "", "Resolve each finding in one of these ways, using the ID in its heading:", "", "* Keep it: keep the proposed fix, or fix it yourself in the report pull request.", `* Reject it: check out the report branch and run \`ckeditor5-dev-agents reject <id> --reason "…"\`. It records why the finding is wrong in \`${upath.join(target.root, taskStatePath)}/decisions/\`, removes it from \`open.json\`, and reverts its fix. The decision holds until the judged content changes. Commit and push the result.`, "* Dismiss it: `ckeditor5-dev-agents dismiss <id>` does the same without a decision, so it may be reported again once the judged content changes.", "", "Reverting a fix by hand, without rejecting the finding, means the task finds the problem and fixes it again on a later run.", "");
943
+ const renderFinding = (finding) => {
944
+ const findingLines = [
945
+ `#### ${finding.discriminator ? `${finding.ruleId}: ${finding.discriminator}` : finding.ruleId} \`[${finding.id}]\``,
946
+ "",
947
+ `<!-- fp: ${finding.fingerprint} -->`,
948
+ "",
949
+ finding.detail,
950
+ ""
951
+ ];
952
+ if (finding.fix) {
953
+ const files = finding.fix.files.map((file) => `\`${file}\``).join(", ");
954
+ findingLines.push(`**Proposed fix:** ${files}. Review it in the diff.`, "");
955
+ }
956
+ if (finding.previousDecision) {
957
+ const { file, fragment, finding: claim, reason } = finding.previousDecision;
958
+ const historyUrl = getHistoryUrl(target, branch, `${taskStatePath}/decisions/${file}`);
959
+ findingLines.push("> [!NOTE]", `> This finding was rejected before, in \`${file}\` ([history](${historyUrl})). It is reported again because the judged content changed: \`${fragment}\` → \`${finding.fragment}\`.`, ...claim ? [">", `> **Previous claim:** ${inline(claim)}`] : [], ">", `> **Answer:** ${inline(reason)}`, "");
960
+ }
961
+ return findingLines;
962
+ };
963
+ const renderFindings = (heading, findings) => {
964
+ if (!findings.length) return;
965
+ lines.push(`## ${heading}`, "");
966
+ const byUnit = /* @__PURE__ */ new Map();
967
+ for (const finding of findings) byUnit.set(finding.unit, [...byUnit.get(finding.unit) ?? [], finding]);
968
+ for (const [unit, unitFindings] of byUnit) {
969
+ const { path } = unitFindings[0];
970
+ lines.push(`### ${path ? `[\`${unit}\`](${getBlobUrl(target, branch, path)})` : `\`${unit}\``}`, "");
971
+ for (const finding of unitFindings) lines.push(...renderFinding(finding));
972
+ }
973
+ };
974
+ renderFindings("Fixed in the report pull request", fixed);
975
+ renderFindings("Open", unresolved);
976
+ return lines.join("\n");
977
+ }
978
+ //#endregion
979
+ //#region src/utils/text.ts
980
+ /**
981
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
982
+ * For licensing, see LICENSE.md.
983
+ */
984
+ const HASH_LENGTH = 12;
985
+ const CHARACTERS = {
986
+ "‘": "'",
987
+ "’": "'",
988
+ "‚": "'",
989
+ "‛": "'",
990
+ "′": "'",
991
+ "“": "\"",
992
+ "”": "\"",
993
+ "„": "\"",
994
+ "‟": "\"",
995
+ "″": "\"",
996
+ "‐": "-",
997
+ "‑": "-",
998
+ "‒": "-",
999
+ "–": "-",
1000
+ "—": "-",
1001
+ "―": "-",
1002
+ "−": "-",
1003
+ "…": "..."
1004
+ };
1005
+ const CHARACTERS_PATTERN = new RegExp(`[${Object.keys(CHARACTERS).join("")}]`, "g");
1006
+ /**
1007
+ * Normalises prose by folding HTML entities, typographic quotes and dashes, and whitespace.
1008
+ * Use only when these differences are irrelevant to the audit; whitespace and entities can be significant in code.
1009
+ */
1010
+ function normalise(text) {
1011
+ return decodeHTMLStrict(text).replace(CHARACTERS_PATTERN, (character) => CHARACTERS[character]).replace(/\s+/g, " ").trim();
1012
+ }
1013
+ /**
1014
+ * Returns a short, stable hash of a text. It is what the baseline and the decision files store.
1015
+ */
1016
+ function hash$1(text) {
1017
+ return hash("sha256", text, "hex").slice(0, HASH_LENGTH);
1018
+ }
1019
+ //#endregion
1020
+ //#region src/runner/createcontext.ts
1021
+ /**
1022
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
1023
+ * For licensing, see LICENSE.md.
1024
+ */
1025
+ /**
1026
+ * Creates the `ctx` of the judging phase. It can read the target, but not change it.
1027
+ */
1028
+ function createTaskContext({ root, phase, options, agent, log }) {
1029
+ return {
1030
+ phase,
1031
+ options,
1032
+ agent,
1033
+ log,
1034
+ normalise,
1035
+ hash: hash$1,
1036
+ repo: {
1037
+ root,
1038
+ async list(patterns, { ignore = [] } = {}) {
1039
+ const entries = glob(patterns, {
1040
+ cwd: root,
1041
+ withFileTypes: true,
1042
+ exclude: ["**/node_modules/**", ...ignore]
1043
+ });
1044
+ const files = [];
1045
+ for await (const entry of entries) if (!entry.isDirectory()) files.push(upath.relative(root, upath.join(entry.parentPath, entry.name)));
1046
+ return files.sort();
1047
+ },
1048
+ async read(path) {
1049
+ return readFile(resolveInside(root, path), "utf8");
1050
+ }
1051
+ }
1052
+ };
1053
+ }
1054
+ /**
1055
+ * Creates the `ctx` of `fix()` and `verify()`. It can also change files and run commands in the target root.
1056
+ */
1057
+ function createWritableTaskContext(options) {
1058
+ const context = createTaskContext(options);
1059
+ return {
1060
+ ...context,
1061
+ repo: {
1062
+ ...context.repo,
1063
+ async write(path, content) {
1064
+ const absolutePath = resolveInside(options.root, path);
1065
+ await mkdir(upath.dirname(absolutePath), { recursive: true });
1066
+ await writeFile(absolutePath, content);
1067
+ },
1068
+ async remove(path) {
1069
+ await rm(resolveInside(options.root, path), { force: true });
1070
+ }
1071
+ },
1072
+ exec(command, args = [], { timeout } = {}) {
1073
+ return execute({
1074
+ command,
1075
+ args,
1076
+ cwd: options.root,
1077
+ timeout
1078
+ });
1079
+ }
1080
+ };
1081
+ }
1082
+ function resolveInside(root, path) {
1083
+ const absolutePath = upath.resolve(root, path);
1084
+ if (isOutside(upath.relative(root, absolutePath))) throw new Error(`The "${path}" path is outside of the target root.`);
1085
+ return absolutePath;
1086
+ }
1087
+ function execute({ command, args, cwd, timeout }) {
1088
+ return new Promise((resolve, reject) => {
1089
+ const child = spawn(command, args, {
1090
+ cwd,
1091
+ timeout,
1092
+ stdio: [
1093
+ "ignore",
1094
+ "pipe",
1095
+ "pipe"
1096
+ ]
1097
+ });
1098
+ let output = "";
1099
+ const append = (data) => {
1100
+ output = (output + data).slice(-4e3);
1101
+ };
1102
+ child.stdout.setEncoding("utf8").on("data", append);
1103
+ child.stderr.setEncoding("utf8").on("data", append);
1104
+ child.on("error", reject);
1105
+ child.on("close", (code, signal) => {
1106
+ if (code === 0) {
1107
+ resolve({ output });
1108
+ return;
1109
+ }
1110
+ let reason = `exited with code ${code}`;
1111
+ if (timeout && child.killed) reason = `did not finish within ${timeout} ms`;
1112
+ else if (signal) reason = `was stopped by ${signal}`;
1113
+ reject(/* @__PURE__ */ new Error(`"${[command, ...args].join(" ")}" ${reason}:\n${output}`));
1114
+ });
1115
+ });
1116
+ }
1117
+ //#endregion
1118
+ //#region src/runner/changetracker.ts
1119
+ /**
1120
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
1121
+ * For licensing, see LICENSE.md.
1122
+ */
1123
+ const MAX_GIT_OUTPUT_SIZE = 268435456;
1124
+ const execFileAsync = promisify(execFile);
1125
+ /**
1126
+ * Tracks what `fix()` changes in a git working tree. It sees every file git sees: tracked files and untracked files
1127
+ * that are not ignored. The working tree may already have changes (the kept fixes of earlier units, or local changes
1128
+ * in a local run). Only what changes after a snapshot counts.
1129
+ *
1130
+ * A snapshot stores the working tree as a tree object, written through a copy of the index, so the real index
1131
+ * is never touched.
1132
+ */
1133
+ async function createChangeTracker(root) {
1134
+ const top = upath.normalize((await git(root, ["rev-parse", "--show-toplevel"])).trim());
1135
+ const indexPath = (await git(top, [
1136
+ "rev-parse",
1137
+ "--path-format=absolute",
1138
+ "--git-path",
1139
+ "index"
1140
+ ])).trim();
1141
+ const snapshotIndexPath = `${indexPath}.ckeditor5-dev-agents`;
1142
+ const snapshotIndex = { GIT_INDEX_FILE: snapshotIndexPath };
1143
+ await rm(snapshotIndexPath, { force: true });
1144
+ const rawContent = {
1145
+ args: [
1146
+ "-c",
1147
+ "core.autocrlf=false",
1148
+ "-c",
1149
+ `core.attributesFile=${snapshotIndexPath}.attributes`
1150
+ ],
1151
+ env: { GIT_ATTR_SOURCE: (await git(top, ["write-tree"], snapshotIndex)).trim() }
1152
+ };
1153
+ const getHead = async () => (await git(top, ["rev-parse", "HEAD"])).trim();
1154
+ const writeTree = async () => {
1155
+ try {
1156
+ await readIfExists(() => cp(indexPath, snapshotIndexPath, { preserveTimestamps: true }));
1157
+ await git(top, [
1158
+ ...rawContent.args,
1159
+ "add",
1160
+ "--all"
1161
+ ], {
1162
+ ...snapshotIndex,
1163
+ ...rawContent.env
1164
+ });
1165
+ return (await git(top, ["write-tree"], snapshotIndex)).trim();
1166
+ } finally {
1167
+ await rm(snapshotIndexPath, { force: true });
1168
+ }
1169
+ };
1170
+ return {
1171
+ async capture() {
1172
+ return {
1173
+ head: await getHead(),
1174
+ tree: await writeTree(),
1175
+ index: await readIfExists(() => readFile(indexPath))
1176
+ };
1177
+ },
1178
+ async collect(snapshot) {
1179
+ const tree = await writeTree();
1180
+ return {
1181
+ changed: (await git(top, [
1182
+ "diff-tree",
1183
+ "-r",
1184
+ "--name-only",
1185
+ "-z",
1186
+ "--no-renames",
1187
+ snapshot.tree,
1188
+ tree
1189
+ ])).split("\0").filter(Boolean).map((path) => upath.join(top, path)).sort(),
1190
+ headMoved: await getHead() !== snapshot.head
1191
+ };
1192
+ },
1193
+ async restore(snapshot, paths) {
1194
+ if (await getHead() !== snapshot.head) await git(top, [
1195
+ "reset",
1196
+ "--mixed",
1197
+ "--quiet",
1198
+ snapshot.head
1199
+ ]);
1200
+ if (paths.length) {
1201
+ const inSnapshot = (await git(top, [
1202
+ "--literal-pathspecs",
1203
+ "ls-tree",
1204
+ "-r",
1205
+ "--name-only",
1206
+ "-z",
1207
+ snapshot.tree,
1208
+ "--",
1209
+ ...paths.map((path) => upath.relative(top, path))
1210
+ ])).split("\0").filter(Boolean);
1211
+ for (const path of paths) await rm(path, {
1212
+ force: true,
1213
+ recursive: true
1214
+ });
1215
+ if (inSnapshot.length) await git(top, [
1216
+ ...rawContent.args,
1217
+ "--literal-pathspecs",
1218
+ "restore",
1219
+ "--source",
1220
+ snapshot.tree,
1221
+ "--worktree",
1222
+ "--",
1223
+ ...inSnapshot
1224
+ ], rawContent.env);
1225
+ }
1226
+ if (snapshot.index) await writeFile(indexPath, snapshot.index);
1227
+ else await rm(indexPath, { force: true });
1228
+ }
1229
+ };
1230
+ }
1231
+ /**
1232
+ * Runs git directly, because simple-git rejects a custom environment together with the `GIT_CONFIG_*` variables that
1233
+ * may be set for the process.
1234
+ */
1235
+ async function git(cwd, args, env = {}) {
1236
+ const { stdout } = await execFileAsync("git", args, {
1237
+ cwd,
1238
+ env: {
1239
+ ...process.env,
1240
+ ...env
1241
+ },
1242
+ encoding: "utf8",
1243
+ maxBuffer: MAX_GIT_OUTPUT_SIZE
1244
+ });
1245
+ return stdout;
1246
+ }
1247
+ //#endregion
1248
+ //#region src/runner/fixunit.ts
1249
+ /**
1250
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
1251
+ * For licensing, see LICENSE.md.
1252
+ */
1253
+ /**
1254
+ * Fixes one unit transactionally. Every affected unit is checked, including units whose hash depends on
1255
+ * files outside their own path. A later fix must preserve the accepted judgments of earlier fixes.
1256
+ */
1257
+ async function fixUnit(runner, record, actionable) {
1258
+ const { task, shared, tracker, fixContext, verifyContext } = runner;
1259
+ const { unit } = record;
1260
+ const snapshot = await tracker.capture();
1261
+ try {
1262
+ await step("`fix()`", () => task.fix({
1263
+ ...fixContext,
1264
+ shared,
1265
+ unit,
1266
+ findings: actionable
1267
+ }));
1268
+ const initial = await step("Inspecting the fix", () => tracker.collect(snapshot));
1269
+ assertAllowedChanges(runner, initial);
1270
+ if (!initial.changed.length) return { status: "none" };
1271
+ await step("`verify()`", () => task.verify?.({
1272
+ ...verifyContext,
1273
+ shared,
1274
+ unit
1275
+ }));
1276
+ const final = await step("Inspecting the fix", () => tracker.collect(snapshot));
1277
+ assertAllowedChanges(runner, final);
1278
+ const affected = await step("Judging the fixed unit", async () => {
1279
+ const fragments = /* @__PURE__ */ new Map();
1280
+ for (const candidate of runner.units.values()) {
1281
+ if (candidate !== record && !candidate.current) continue;
1282
+ const fragment = await step(`\`contentHash()\` of "${candidate.unit.key}"`, () => runner.contentHash(candidate.unit));
1283
+ if (candidate === record || fragment !== candidate.current?.fragment) fragments.set(candidate, fragment);
1284
+ }
1285
+ const unjudged = [...fragments.keys()].filter((candidate) => !candidate.current?.findings);
1286
+ if (unjudged.length) {
1287
+ const proposed = await tracker.capture();
1288
+ await tracker.restore(snapshot, final.changed);
1289
+ try {
1290
+ for (const candidate of unjudged) {
1291
+ const fragment = await runner.contentHash(candidate.unit);
1292
+ candidate.current = {
1293
+ fragment,
1294
+ findings: await runner.judge(candidate.unit, fragment)
1295
+ };
1296
+ }
1297
+ } finally {
1298
+ await tracker.restore(proposed, final.changed);
1299
+ }
1300
+ }
1301
+ return fragments;
1302
+ });
1303
+ const observations = /* @__PURE__ */ new Map();
1304
+ const resolved = /* @__PURE__ */ new Map();
1305
+ for (const [candidate, fragment] of affected) {
1306
+ const findings = await step("Judging the fixed unit", () => runner.judge(candidate.unit, fragment));
1307
+ const before = candidate.current.findings;
1308
+ const known = new Set(before.filter((finding) => !runner.isRejected(finding)).map((finding) => finding.fingerprint));
1309
+ const rejected = new Set(before.filter((finding) => runner.isRejected(finding)).map((finding) => finding.fingerprint));
1310
+ const introduced = findings.filter((finding) => {
1311
+ return !known.has(finding.fingerprint) && !rejected.has(finding.fingerprint) && !runner.isRejected(finding);
1312
+ });
1313
+ if (introduced.length) throw new Error(`The fix introduced new findings: ${introduced.map((finding) => finding.ruleId).join(", ")}.`);
1314
+ const remaining = new Set(findings.map((finding) => finding.fingerprint));
1315
+ resolved.set(candidate, new Set([...known].filter((fingerprint) => !remaining.has(fingerprint))));
1316
+ observations.set(candidate, {
1317
+ fragment,
1318
+ findings
1319
+ });
1320
+ }
1321
+ if (!actionable.some((finding) => resolved.get(record).has(finding.fingerprint))) throw new Error("The fix did not resolve any finding.");
1322
+ for (const [candidate, observation] of observations) candidate.current = observation;
1323
+ return {
1324
+ status: "kept",
1325
+ files: final.changed,
1326
+ resolved
1327
+ };
1328
+ } catch (error) {
1329
+ await tracker.restore(snapshot, (await tracker.collect(snapshot)).changed);
1330
+ return {
1331
+ status: "discarded",
1332
+ message: getErrorMessage(error),
1333
+ findings: actionable.length
1334
+ };
1335
+ }
1336
+ }
1337
+ /**
1338
+ * Prepends the step to the message when it fails. The checks of the harness throw outside of a step, because their
1339
+ * messages are complete on their own.
1340
+ */
1341
+ async function step(label, callback) {
1342
+ try {
1343
+ return await callback();
1344
+ } catch (error) {
1345
+ throw new Error(`${label} failed: ${getErrorMessage(error)}`, { cause: error });
1346
+ }
1347
+ }
1348
+ function assertAllowedChanges(runner, { changed, headMoved }) {
1349
+ if (headMoved) throw new Error("The fix created commits. Only the harness may commit.");
1350
+ const outside = changed.map((file) => upath.relative(runner.root, file)).filter((file) => isOutside(file) || !runner.task.writes.some((pattern) => posix.matchesGlob(file, pattern)));
1351
+ if (outside.length) throw new Error(`The fix changed files outside \`writes\`: ${outside.map((file) => `\`${file}\``).join(", ")}.`);
1352
+ }
1353
+ //#endregion
1354
+ //#region src/runner/validatefindings.ts
1355
+ /**
1356
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
1357
+ * For licensing, see LICENSE.md.
1358
+ */
1359
+ const KNOWN_FIELDS = [
1360
+ "fingerprint",
1361
+ "id",
1362
+ "task",
1363
+ "unit",
1364
+ "ruleId",
1365
+ "discriminator",
1366
+ "detail",
1367
+ "fragment",
1368
+ "path",
1369
+ "reportedAt",
1370
+ "previousDecision",
1371
+ "fix"
1372
+ ];
1373
+ /**
1374
+ * Returns the identity of a finding. Two findings with the same fingerprint are the same claim,
1375
+ * so the harness reports it only once.
1376
+ */
1377
+ function getFingerprint({ task, unit, ruleId, discriminator }) {
1378
+ return [
1379
+ task,
1380
+ unit,
1381
+ ruleId,
1382
+ discriminator
1383
+ ].map((part) => part.replace(/[\\|]/g, "\\$&")).join("|");
1384
+ }
1385
+ /**
1386
+ * Returns the full digest of a fingerprint. Its first 12 characters are the `id` of the finding. Longer prefixes
1387
+ * resolve collisions between IDs.
1388
+ */
1389
+ function getFindingId(fingerprint) {
1390
+ return hash("sha256", fingerprint, "hex");
1391
+ }
1392
+ /**
1393
+ * Checks what `judge()` returned and turns it into findings the harness can store.
1394
+ * It throws when anything is malformed, because a judge that half-worked must not advance the baseline.
1395
+ */
1396
+ function validateFindings(findings, { task, unit, fragment }) {
1397
+ if (!Array.isArray(findings)) throw new Error("`judge()` must return an array of findings.");
1398
+ return findings.map((finding, index) => {
1399
+ if (!finding || typeof finding !== "object" || Array.isArray(finding)) throw new Error(`Finding #${index} must be an object.`);
1400
+ const fields = finding;
1401
+ if (typeof fields.ruleId !== "string" || !task.ruleIds.includes(fields.ruleId)) throw new Error(`Finding #${index} refers to the unknown "${String(fields.ruleId)}" rule.`);
1402
+ if (typeof fields.detail !== "string" || fields.detail.trim() === "") throw new Error(`Finding #${index} must have a non-empty "detail".`);
1403
+ if (fields.discriminator !== void 0 && typeof fields.discriminator !== "string") throw new Error(`The "discriminator" of finding #${index} must be a string.`);
1404
+ const extraFields = Object.fromEntries(Object.entries(fields).filter(([key]) => !KNOWN_FIELDS.includes(key)));
1405
+ const identity = {
1406
+ task: task.id,
1407
+ unit: unit.key,
1408
+ ruleId: fields.ruleId,
1409
+ discriminator: fields.discriminator ?? ""
1410
+ };
1411
+ const fingerprint = getFingerprint(identity);
1412
+ return {
1413
+ fingerprint,
1414
+ id: hash$1(fingerprint),
1415
+ ...identity,
1416
+ detail: fields.detail.trim(),
1417
+ fragment,
1418
+ ...unit.path ? { path: unit.path } : {},
1419
+ ...extraFields
1420
+ };
1421
+ });
1422
+ }
1423
+ //#endregion
1424
+ //#region src/runner/runtask.ts
1425
+ /**
1426
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
1427
+ * For licensing, see LICENSE.md.
1428
+ */
1429
+ const DEFAULT_CONCURRENCY = 1;
1430
+ /**
1431
+ * Judges a bounded batch, applies verified fixes, and writes the state for the caller to publish.
1432
+ * Failed units and discarded fixes keep their previous baseline so the next run retries them.
1433
+ * Existing open findings remain until a human resolves them or a new report prunes reviewed fixes.
1434
+ */
1435
+ async function runTask(runOptions) {
1436
+ const { instance: { task, directory }, target, branch, today, pruneFixed, log } = runOptions;
1437
+ const { options, include, exclude } = task;
1438
+ const root = upath.normalize(await realpath(runOptions.root));
1439
+ const statePath = upath.join(root, STATE_DIRECTORY, task.id);
1440
+ const state = await readTaskState(statePath);
1441
+ const usage = {
1442
+ cost: 0,
1443
+ tokens: 0
1444
+ };
1445
+ const agentWarnings = /* @__PURE__ */ new Set();
1446
+ const createPhaseAgent = (tools) => createAgent({
1447
+ taskId: task.id,
1448
+ config: task.agent,
1449
+ taskDirectory: directory,
1450
+ cwd: root,
1451
+ tools,
1452
+ usage,
1453
+ log,
1454
+ warn: (message) => agentWarnings.add(message)
1455
+ });
1456
+ const judgeAgent = createPhaseAgent(task.agent?.judgeTools ?? READ_ONLY_TOOLS);
1457
+ const fixAgent = createPhaseAgent(task.agent?.fixTools ?? [
1458
+ ...READ_ONLY_TOOLS,
1459
+ "edit",
1460
+ "write"
1461
+ ]);
1462
+ const judgeContext = createTaskContext({
1463
+ root,
1464
+ phase: "judge",
1465
+ options,
1466
+ agent: judgeAgent,
1467
+ log
1468
+ });
1469
+ const fixContext = createWritableTaskContext({
1470
+ root,
1471
+ phase: "fix",
1472
+ options,
1473
+ agent: fixAgent,
1474
+ log
1475
+ });
1476
+ const verifyContext = createWritableTaskContext({
1477
+ root,
1478
+ phase: "verify",
1479
+ options,
1480
+ agent: fixAgent,
1481
+ log
1482
+ });
1483
+ const shared = task.prepare ? await task.prepare(judgeContext) : void 0;
1484
+ const units = task.scope ? await task.scope({
1485
+ ...judgeContext,
1486
+ shared,
1487
+ include,
1488
+ exclude
1489
+ }) : (await judgeContext.repo.list(include, { ignore: exclude })).map((path) => ({
1490
+ key: path,
1491
+ path
1492
+ }));
1493
+ const { decisions } = state;
1494
+ const operations = {
1495
+ async contentHash(unit) {
1496
+ return task.contentHash({
1497
+ ...judgeContext,
1498
+ shared,
1499
+ unit
1500
+ });
1501
+ },
1502
+ async judge(unit, fragment) {
1503
+ const payload = await task.payload({
1504
+ ...judgeContext,
1505
+ shared,
1506
+ unit
1507
+ });
1508
+ const priorDecisions = getPriorDecisions(decisions, unit.key, fragment);
1509
+ const findings = validateFindings(await task.judge({
1510
+ ...judgeContext,
1511
+ shared,
1512
+ unit,
1513
+ payload,
1514
+ priorDecisions
1515
+ }), {
1516
+ task,
1517
+ unit,
1518
+ fragment
1519
+ });
1520
+ return [...new Map(findings.map((finding) => [finding.fingerprint, finding])).values()];
1521
+ },
1522
+ isRejected(finding) {
1523
+ return findDecisions(decisions, finding).some((decision) => decision.fragment === finding.fragment);
1524
+ }
1525
+ };
1526
+ const unitRuns = /* @__PURE__ */ new Map();
1527
+ for (const unit of units.toSorted((a, b) => compare(a.key, b.key))) {
1528
+ if (unitRuns.has(unit.key)) throw new Error(`\`scope()\` returned the "${unit.key}" unit more than once.`);
1529
+ unitRuns.set(unit.key, {
1530
+ unit,
1531
+ fixes: /* @__PURE__ */ new Map()
1532
+ });
1533
+ }
1534
+ for (const record of unitRuns.values()) try {
1535
+ record.current = { fragment: await operations.contentHash(record.unit) };
1536
+ } catch (error) {
1537
+ record.error = `contentHash() failed: ${getErrorMessage(error)}`;
1538
+ }
1539
+ const baseline = Object.fromEntries(Object.entries(state.baseline).filter(([key]) => unitRuns.has(key)));
1540
+ const open = state.open.filter((finding) => !pruneFixed || !finding.fix);
1541
+ const openFingerprints = new Set(open.map((finding) => finding.fingerprint));
1542
+ const changedUnits = [...unitRuns.values()].filter(({ unit, current }) => current && current.fragment !== baseline[unit.key]);
1543
+ const takenUnits = changedUnits.slice(0, task.maxUnits);
1544
+ const deferred = changedUnits.length - takenUnits.length;
1545
+ log(`${units.length} units in scope, ${changedUnits.length} changed, ${takenUnits.length} taken in this run` + (deferred ? ` (${deferred} left for the next runs).` : "."));
1546
+ if (takenUnits.length) log(`Judging ${takenUnits.length} units...`);
1547
+ const queue = [...takenUnits];
1548
+ await Promise.all(Array.from({ length: task.concurrency ?? DEFAULT_CONCURRENCY }, async () => {
1549
+ for (let record = queue.shift(); record; record = queue.shift()) try {
1550
+ record.judged = await operations.judge(record.unit, record.current.fragment);
1551
+ record.current.findings = record.judged;
1552
+ } catch (error) {
1553
+ record.error = getErrorMessage(error);
1554
+ }
1555
+ }));
1556
+ const getActionable = (record) => {
1557
+ return record.judged ? record.current.findings.filter((finding) => !operations.isRejected(finding)) : [];
1558
+ };
1559
+ const toFix = takenUnits.filter((record) => getActionable(record).length).length;
1560
+ let files = [];
1561
+ if (task.fix && toFix) {
1562
+ const tracker = await createChangeTracker(root);
1563
+ const beforeFixes = await tracker.capture();
1564
+ const fixer = {
1565
+ ...operations,
1566
+ task,
1567
+ shared,
1568
+ root,
1569
+ units: unitRuns,
1570
+ tracker,
1571
+ fixContext,
1572
+ verifyContext
1573
+ };
1574
+ log(`Fixing ${toFix} units...`);
1575
+ for (const record of takenUnits) {
1576
+ const actionable = getActionable(record);
1577
+ if (!actionable.length) continue;
1578
+ const outcome = await fixUnit(fixer, record, actionable);
1579
+ if (outcome.status === "kept") {
1580
+ const fix = {
1581
+ files: outcome.files.map((file) => upath.relative(root, file)),
1582
+ fixedAt: today
1583
+ };
1584
+ for (const [affected, fingerprints] of outcome.resolved) for (const fingerprint of fingerprints) affected.fixes.set(fingerprint, fix);
1585
+ } else if (outcome.status === "discarded") record.discarded = outcome;
1586
+ }
1587
+ files = (await tracker.collect(beforeFixes)).changed;
1588
+ }
1589
+ const judged = [...unitRuns.values()].filter((record) => record.judged);
1590
+ const counts = {
1591
+ newFindings: 0,
1592
+ alreadyOpen: 0,
1593
+ rejected: 0
1594
+ };
1595
+ for (const { unit, current, judged: findings, discarded } of judged) {
1596
+ for (const finding of findings) if (openFingerprints.has(finding.fingerprint)) counts.alreadyOpen++;
1597
+ else if (operations.isRejected(finding)) counts.rejected++;
1598
+ else {
1599
+ const decision = findDecisions(decisions, finding).at(-1);
1600
+ counts.newFindings++;
1601
+ open.push({
1602
+ ...finding,
1603
+ reportedAt: today,
1604
+ previousDecision: decision && {
1605
+ file: decision.file,
1606
+ fragment: decision.fragment,
1607
+ finding: decision.finding,
1608
+ reason: decision.reason
1609
+ }
1610
+ });
1611
+ }
1612
+ if (!discarded) baseline[unit.key] = current.fragment;
1613
+ }
1614
+ const fixed = open.filter((finding) => unitRuns.get(finding.unit)?.fixes.has(finding.fingerprint));
1615
+ for (const finding of fixed) {
1616
+ const record = unitRuns.get(finding.unit);
1617
+ finding.fix = {
1618
+ ...record.fixes.get(finding.fingerprint),
1619
+ fragment: record.current.fragment
1620
+ };
1621
+ }
1622
+ await writeTaskState(statePath, {
1623
+ baseline,
1624
+ open,
1625
+ report: renderReport({
1626
+ task,
1627
+ target,
1628
+ branch,
1629
+ open
1630
+ })
1631
+ });
1632
+ const errors = [...unitRuns.values()].filter((record) => record.error !== void 0).map(({ unit, error }) => ({
1633
+ unit: unit.key,
1634
+ message: error
1635
+ }));
1636
+ const discardedRuns = judged.filter((record) => record.discarded);
1637
+ return {
1638
+ summary: {
1639
+ task: task.id,
1640
+ title: task.title,
1641
+ units: units.length,
1642
+ changed: changedUnits.length,
1643
+ judged: judged.length,
1644
+ deferred,
1645
+ ...counts,
1646
+ fixed: fixed.length,
1647
+ discarded: discardedRuns.reduce((total, record) => total + record.discarded.findings, 0),
1648
+ changedFiles: files.length,
1649
+ open: open.filter((finding) => !finding.fix).length,
1650
+ errors: errors.length,
1651
+ problems: [
1652
+ ...errors,
1653
+ ...discardedRuns.map(({ unit, discarded }) => ({
1654
+ unit: unit.key,
1655
+ message: `The fix was discarded. ${discarded.message}`
1656
+ })),
1657
+ ...[...state.warnings, ...agentWarnings].map((message) => ({ message }))
1658
+ ],
1659
+ cost: usage.cost,
1660
+ tokens: usage.tokens
1661
+ },
1662
+ files,
1663
+ open
1664
+ };
1665
+ }
1666
+ //#endregion
1667
+ //#region src/runner/runtarget.ts
1668
+ /**
1669
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
1670
+ * For licensing, see LICENSE.md.
1671
+ */
1672
+ /**
1673
+ * Runs the selected tasks against one target, in the working tree of the checkout.
1674
+ *
1675
+ * Both flows judge, fix, and write the state in the checkout. Kept fixes and the state stay in the files. A local run
1676
+ * stops there: nothing is committed or pushed. A published run does every task on its own report branch: it continues
1677
+ * the open report pull request or starts a new branch at the base branch, commits and pushes once, opens or updates
1678
+ * the pull request, and checks out the base branch again.
1679
+ */
1680
+ async function runTarget(options) {
1681
+ const { target, workspace, github, log } = options;
1682
+ const instances = [...target.tasks.values()].filter(({ task, enabled }) => {
1683
+ return options.taskIds.length ? options.taskIds.includes(task.id) : enabled;
1684
+ });
1685
+ if (!instances.length) {
1686
+ log(`No tasks are enabled for the "${target.name}" target. Skipping.`);
1687
+ return [];
1688
+ }
1689
+ const root = upath.join(workspace.path, target.root);
1690
+ if (!existsSync(root)) throw new Error(`The root of the "${target.name}" target, "${target.root}", does not exist in the checkout.`);
1691
+ const results = [];
1692
+ for (const instance of instances) {
1693
+ const { task } = instance;
1694
+ const startedAt = Date.now();
1695
+ let result;
1696
+ log(`Running the "${task.id}" task...`);
1697
+ try {
1698
+ result = github ? await publishTask(options, github, root, instance) : { summary: (await executeTask(options, root, instance, false)).summary };
1699
+ } catch (error) {
1700
+ result = { failure: getErrorMessage(error) };
1701
+ }
1702
+ if (result.failure) log(` The "${task.id}" task failed: ${result.failure}`);
1703
+ results.push({
1704
+ task: task.id,
1705
+ title: task.title,
1706
+ durationMs: Date.now() - startedAt,
1707
+ ...result
1708
+ });
1709
+ }
1710
+ return results;
1711
+ }
1712
+ function getReportBranch(taskId, target, branch) {
1713
+ return `${REPORT_BRANCH_PREFIX}/${taskId}/${target.name}/${branch}`;
1714
+ }
1715
+ function executeTask(options, root, instance, pruneFixed) {
1716
+ const { target, workspace, today, log } = options;
1717
+ return runTask({
1718
+ instance,
1719
+ target,
1720
+ root,
1721
+ branch: workspace.branch,
1722
+ today,
1723
+ pruneFixed,
1724
+ log: (message) => log(` ${message}`)
1725
+ });
1726
+ }
1727
+ async function publishTask(options, github, root, instance) {
1728
+ const { target, workspace, today, buildUrl, log } = options;
1729
+ const { task } = instance;
1730
+ const { branch } = workspace;
1731
+ const reportBranch = getReportBranch(task.id, target, branch);
1732
+ let pullRequest = await github.findOpenPullRequest({
1733
+ slug: target.slug,
1734
+ head: reportBranch
1735
+ });
1736
+ let saved;
1737
+ let failure;
1738
+ try {
1739
+ await workspace.checkoutReportBranch({
1740
+ name: reportBranch,
1741
+ continueExisting: Boolean(pullRequest)
1742
+ });
1743
+ log(pullRequest ? ` Continuing the open report pull request: ${pullRequest.html_url}` : ` Starting the "${reportBranch}" report branch at "${branch}".`);
1744
+ saved = await executeTask(options, root, instance, !pullRequest);
1745
+ const paths = [upath.join(target.root, STATE_DIRECTORY, task.id), ...saved.files.map((file) => upath.relative(workspace.path, file))];
1746
+ const judged = saved.summary.judged;
1747
+ if (await workspace.commit(paths, `${task.title}: ${judged} ${plural(judged, "unit")} judged.`)) {
1748
+ await workspace.push();
1749
+ const fixed = saved.open.filter((finding) => finding.fix).length;
1750
+ const body = renderPullRequestBody({
1751
+ task,
1752
+ target,
1753
+ branch,
1754
+ reportBranch,
1755
+ open: saved.open.length - fixed,
1756
+ fixed
1757
+ });
1758
+ if (pullRequest) await github.updatePullRequest({
1759
+ slug: target.slug,
1760
+ number: pullRequest.number,
1761
+ body
1762
+ });
1763
+ else {
1764
+ pullRequest = await github.createPullRequest({
1765
+ slug: target.slug,
1766
+ head: reportBranch,
1767
+ base: branch,
1768
+ title: `${task.title}: ${target.name} (${branch})`,
1769
+ body
1770
+ });
1771
+ log(` Opened the report pull request: ${pullRequest.html_url}`);
1772
+ }
1773
+ await github.createComment({
1774
+ slug: target.slug,
1775
+ number: pullRequest.number,
1776
+ body: renderRunComment({
1777
+ today,
1778
+ buildUrl,
1779
+ summary: saved.summary
1780
+ })
1781
+ });
1782
+ } else log(" Nothing new. Nothing was pushed.");
1783
+ } catch (error) {
1784
+ failure = getErrorMessage(error);
1785
+ } finally {
1786
+ try {
1787
+ await workspace.returnToBase();
1788
+ } catch (error) {
1789
+ const message = `Returning to the base branch failed: ${getErrorMessage(error)}`;
1790
+ failure = failure ? `${failure}\n${message}` : message;
1791
+ }
1792
+ }
1793
+ return saved ? {
1794
+ summary: saved.summary,
1795
+ failure,
1796
+ pullRequestUrl: pullRequest?.html_url
1797
+ } : { failure };
1798
+ }
1799
+ //#endregion
1800
+ //#region src/runtasks.ts
1801
+ /**
1802
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
1803
+ * For licensing, see LICENSE.md.
1804
+ */
1805
+ /**
1806
+ * Runs the tasks against the targets in the checkout. The result is not `ok` when a task or a unit failed. Findings
1807
+ * never make a run fail.
1808
+ */
1809
+ async function runTasks(options) {
1810
+ const { log = console.log, tasks: taskIds, token } = options;
1811
+ const targets = await loadConfig(options.configPath);
1812
+ const workspace = await openWorkspace({
1813
+ cwd: options.cwd,
1814
+ author: options.author
1815
+ });
1816
+ const selectedTargets = selectTargets(targets, {
1817
+ originSlug: workspace.originSlug,
1818
+ names: options.targets,
1819
+ taskIds
1820
+ });
1821
+ const publish = Boolean(token);
1822
+ const github = publish ? createGitHubClient(token) : void 0;
1823
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1824
+ const { branch } = workspace;
1825
+ const results = [];
1826
+ if (publish) await workspace.assertPublishable(selectedTargets.flatMap((target) => [...target.tasks.keys()].map((taskId) => {
1827
+ return upath.join(target.root, STATE_DIRECTORY, taskId, "open.json");
1828
+ })));
1829
+ for (const target of selectedTargets) {
1830
+ const targetResult = {
1831
+ target: target.name,
1832
+ branch,
1833
+ publish
1834
+ };
1835
+ log(`\n○ Running "${target.name}" (${target.slug}, ${branch})...`);
1836
+ try {
1837
+ results.push({
1838
+ ...targetResult,
1839
+ results: await runTarget({
1840
+ target,
1841
+ taskIds,
1842
+ workspace,
1843
+ github,
1844
+ today,
1845
+ buildUrl: options.buildUrl,
1846
+ log: (message) => log(` ${message}`)
1847
+ })
1848
+ });
1849
+ } catch (error) {
1850
+ log(` Running "${target.name}" failed: ${getErrorMessage(error)}`);
1851
+ results.push({
1852
+ ...targetResult,
1853
+ results: [],
1854
+ error: getErrorMessage(error)
1855
+ });
1856
+ }
1857
+ }
1858
+ return {
1859
+ ok: results.every(({ results: taskResults, error }) => {
1860
+ return !error && taskResults.every(({ failure, summary }) => !failure && !summary?.errors);
1861
+ }),
1862
+ targets: results
1863
+ };
1864
+ }
1865
+ //#endregion
1866
+ //#region src/report/renderconsolesummary.ts
1867
+ /**
1868
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
1869
+ * For licensing, see LICENSE.md.
1870
+ */
1871
+ /**
1872
+ * Renders what a run did, as plain text for the console: one block per task of every target.
1873
+ */
1874
+ function renderConsoleSummary(targets) {
1875
+ return targets.flatMap((target) => {
1876
+ const heading = `${target.target} (${target.branch})`;
1877
+ if (target.error) return [[`${heading} · failed`, row("Error", inline(target.error))].join("\n")];
1878
+ if (!target.results.length) return [`${heading} · no enabled tasks`];
1879
+ return target.results.map((result) => renderTask(result, `${heading} · ${target.publish ? "published" : "local"}`));
1880
+ }).join("\n\n");
1881
+ }
1882
+ function renderTask(result, heading) {
1883
+ const lines = [`${result.title} · ${heading}`];
1884
+ const { summary, failure } = result;
1885
+ if (!summary) {
1886
+ lines.push(row("Failed", inline(failure)), row("Time", formatDuration(result.durationMs)));
1887
+ return lines.join("\n");
1888
+ }
1889
+ const problems = [...failure ? [`Publishing failed: ${inline(failure)}`] : [], ...summary.problems.map(({ unit, message }) => inline(unit ? `${unit}: ${message}` : message))];
1890
+ lines.push(row("Units", `${summary.units} in scope · ${summary.changed} changed · ${summary.judged} judged` + (summary.deferred ? ` · ${summary.deferred} left for the next runs` : "")), row("Findings", `${summary.newFindings} new · ${summary.alreadyOpen} already open · ${summary.rejected} rejected`), row("Fixes", `${summary.fixed} fixed · ${summary.discarded} discarded`), row("Files", `${summary.changedFiles} changed`), row("Open", String(summary.open)), row("Cost", `$${summary.cost.toFixed(2)} · ${summary.tokens} tokens · ${formatDuration(result.durationMs)}`), row("Problems", problems.length ? String(problems.length) : "none"), ...problems.map((problem) => row("", `• ${problem}`)));
1891
+ if (result.pullRequestUrl) lines.push(row("Pull request", result.pullRequestUrl));
1892
+ return lines.join("\n");
1893
+ }
1894
+ function row(label, value) {
1895
+ return ` ${label.padEnd(13)}${value}`;
1896
+ }
1897
+ function formatDuration(milliseconds) {
1898
+ const tenths = Math.round(milliseconds / 100);
1899
+ if (tenths < 600) return `${(tenths / 10).toFixed(1)} s`;
1900
+ const seconds = Math.round(milliseconds / 1e3);
1901
+ return `${Math.floor(seconds / 60)} min ${seconds % 60} s`;
1902
+ }
1903
+ //#endregion
1904
+ //#region src/commands/resolvefinding.ts
1905
+ /**
1906
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
1907
+ * For licensing, see LICENSE.md.
1908
+ */
1909
+ /**
1910
+ * Rejects or dismisses one finding in the working tree of the checkout, by its short ID:
1911
+ *
1912
+ * 1. If the finding has a fix, every file of the fix is restored: from the base branch on a report branch,
1913
+ * from `HEAD` otherwise.
1914
+ * 2. If other fixes changed any of those files, they are lost too. Without `force`, nothing changes and the error
1915
+ * lists them. With `force`, all their files are restored as well, their findings lose the fix, and their units are
1916
+ * judged and fixed again by the next run.
1917
+ * 3. The finding leaves `open.json`. When rejecting, a decision file records the reason.
1918
+ * 4. `report.md` is regenerated. The changes stay in the working tree for a human to review and commit.
1919
+ */
1920
+ async function resolveFinding(options) {
1921
+ const { tasks: taskIds } = options;
1922
+ const targets = await loadConfig(options.configPath);
1923
+ const workspace = await openWorkspace({ cwd: options.cwd });
1924
+ const selectedTargets = selectTargets(targets, {
1925
+ originSlug: workspace.originSlug,
1926
+ names: options.targets,
1927
+ taskIds
1928
+ });
1929
+ const entries = await readStates(workspace, [...targets.values()].filter((target) => target.slug === workspace.originSlug));
1930
+ const { entry, finding } = findFinding(entries.filter((entry) => selectedTargets.includes(entry.target) && (!taskIds.length || taskIds.includes(entry.task.id))), options.id);
1931
+ const reportBranch = parseReportBranch(workspace.branch);
1932
+ if (reportBranch && (reportBranch.task !== entry.task.id || reportBranch.target !== entry.target.name)) throw new Error(`The [${finding.id}] finding belongs to the "${entry.task.id}" task in the "${entry.target.name}" target, but the "${workspace.branch}" report branch is checked out. Check out "${getReportBranch(entry.task.id, entry.target, reportBranch.base)}" first.`);
1933
+ const affected = getAffectedFixes(entries, finding);
1934
+ const others = affected.filter((item) => item.finding !== finding);
1935
+ if (others.length && !options.force) throw new Error([
1936
+ `Reverting the fix of [${finding.id}] also undoes these fixes, because they changed the same files:`,
1937
+ ...others.map(({ finding: other, files }) => {
1938
+ return ` [${other.id}] ${describe(other)} (${other.task}): ${files.join(", ")}`;
1939
+ }),
1940
+ "Nothing was changed. Re-run with \"--force\" to revert them too. Their findings then stay open, and the next run judges and fixes them again."
1941
+ ].join("\n"));
1942
+ const baseBranch = reportBranch?.base;
1943
+ const fixFiles = [...new Set(affected.flatMap((item) => item.files))].sort();
1944
+ const canRestore = !fixFiles.length || Boolean(baseBranch) || await workspace.hasChanges(fixFiles);
1945
+ const revertedFiles = canRestore ? fixFiles : [];
1946
+ if (revertedFiles.length) await workspace.restore(revertedFiles, baseBranch ? await getRestoreRef(workspace, baseBranch) : "HEAD");
1947
+ for (const item of affected) {
1948
+ delete item.entry.state.baseline[item.finding.unit];
1949
+ delete item.finding.fix;
1950
+ }
1951
+ entry.state.open = entry.state.open.filter((candidate) => candidate !== finding);
1952
+ const decisionFile = options.action === "reject" ? await writeDecision(entry, finding, options.reason, workspace) : void 0;
1953
+ for (const { target, task, statePath, state: { baseline, open } } of /* @__PURE__ */ new Set([entry, ...affected.map((item) => item.entry)])) await writeTaskState(statePath, {
1954
+ baseline,
1955
+ open,
1956
+ report: renderReport({
1957
+ task,
1958
+ target,
1959
+ branch: baseBranch ?? workspace.branch,
1960
+ open
1961
+ })
1962
+ });
1963
+ return {
1964
+ action: options.action,
1965
+ target: entry.target.name,
1966
+ finding,
1967
+ decisionFile,
1968
+ revertedFiles,
1969
+ unrevertedFiles: canRestore ? [] : fixFiles,
1970
+ reopened: others.map((item) => item.finding)
1971
+ };
1972
+ }
1973
+ /**
1974
+ * Renders what `resolveFinding()` did, as plain text for the console.
1975
+ */
1976
+ function renderResolvedFinding(result) {
1977
+ const { finding } = result;
1978
+ const lines = [`${result.action === "reject" ? "Rejected" : "Dismissed"} [${finding.id}] ${describe(finding)} (${finding.task}, ${result.target}).`];
1979
+ if (result.decisionFile) lines.push(` Decision ${result.decisionFile}`);
1980
+ if (result.unrevertedFiles.length) lines.push(` Reverted nothing: ${result.unrevertedFiles.join(", ")} had no uncommitted changes. If the fix is committed, revert it by hand.`);
1981
+ else if (result.revertedFiles.length) lines.push(` Reverted ${result.revertedFiles.join(", ")}`);
1982
+ else lines.push(" Reverted nothing (the finding had no fix)");
1983
+ for (const item of result.reopened) lines.push(` Reopened [${item.id}] ${describe(item)} (${item.task}), fixed again on the next run`);
1984
+ lines.push("", "Nothing was committed. Review the changes with \"git status\" and \"git diff HEAD\", then commit them.");
1985
+ return lines.join("\n");
1986
+ }
1987
+ async function readStates(workspace, targets) {
1988
+ const entries = [];
1989
+ for (const target of targets) for (const { task } of target.tasks.values()) {
1990
+ const statePath = upath.join(workspace.path, target.root, STATE_DIRECTORY, task.id);
1991
+ entries.push({
1992
+ target,
1993
+ task,
1994
+ statePath,
1995
+ state: await readTaskState(statePath)
1996
+ });
1997
+ }
1998
+ return entries;
1999
+ }
2000
+ function findFinding(entries, id) {
2001
+ const prefix = id.trim().toLowerCase().replace(/^\[|\]$/g, "");
2002
+ const matches = entries.flatMap((entry) => {
2003
+ return entry.state.open.filter((finding) => getFindingId(finding.fingerprint).startsWith(prefix)).map((finding) => ({
2004
+ entry,
2005
+ finding
2006
+ }));
2007
+ });
2008
+ if (!prefix || !matches.length) throw new Error(`No open finding has the "${id}" ID.`);
2009
+ if (matches.length > 1) throw new Error([`The "${id}" ID matches more than one finding. Use more characters of the ID:`, ...matches.map(({ finding }) => ` [${getFindingId(finding.fingerprint)}] ${describe(finding)} (${finding.task})`)].join("\n"));
2010
+ return matches[0];
2011
+ }
2012
+ /**
2013
+ * Returns the fix of the finding, and every other fix that shares a file with the ones found so far. Restoring a shared
2014
+ * file undoes all of them, so each of them is undone completely.
2015
+ */
2016
+ function getAffectedFixes(entries, finding) {
2017
+ const fixed = entries.flatMap((entry) => entry.state.open.filter((item) => item.fix).map((item) => ({
2018
+ entry,
2019
+ finding: item,
2020
+ files: item.fix.files.map((file) => upath.join(entry.target.root, file))
2021
+ })));
2022
+ const start = fixed.find((item) => item.finding === finding);
2023
+ if (!start) return [];
2024
+ const affected = [start];
2025
+ const files = new Set(start.files);
2026
+ for (let index = 0; index < affected.length; index++) for (const item of fixed) if (!affected.includes(item) && item.files.some((file) => files.has(file))) {
2027
+ affected.push(item);
2028
+ item.files.forEach((file) => files.add(file));
2029
+ }
2030
+ return affected;
2031
+ }
2032
+ /**
2033
+ * Splits the name of a report branch, `ai-tasks/<task>/<target>/<base branch>`, into its parts.
2034
+ */
2035
+ function parseReportBranch(branch) {
2036
+ const [prefix, task, target, ...base] = branch.split("/");
2037
+ return prefix === "ai-tasks" && task && target && base.length ? {
2038
+ task,
2039
+ target,
2040
+ base: base.join("/")
2041
+ } : void 0;
2042
+ }
2043
+ async function getRestoreRef(workspace, baseBranch) {
2044
+ for (const ref of [`origin/${baseBranch}`, baseBranch]) if (await workspace.refExists(ref)) return ref;
2045
+ throw new Error(`The "${baseBranch}" base branch of the "${workspace.branch}" report branch does not exist. Fetch it first.`);
2046
+ }
2047
+ async function writeDecision(entry, finding, reason, workspace) {
2048
+ const directory = upath.join(entry.statePath, "decisions");
2049
+ const name = getDecisionFileName(finding);
2050
+ let path = upath.join(directory, `${name}.yml`);
2051
+ for (let suffix = 2; existsSync(path); suffix++) path = upath.join(directory, `${name}-${suffix}.yml`);
2052
+ await mkdir(directory, { recursive: true });
2053
+ await writeFile(path, renderDecision(finding, reason));
2054
+ return upath.relative(workspace.path, path);
2055
+ }
2056
+ function describe(finding) {
2057
+ return `${finding.ruleId}${finding.discriminator ? ` (${finding.discriminator})` : ""} in ${finding.unit}`;
2058
+ }
2059
+ //#endregion
2060
+ //#region src/cli.ts
2061
+ /**
2062
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
2063
+ * For licensing, see LICENSE.md.
2064
+ */
2065
+ const COMMON_OPTIONS = {
2066
+ config: {
2067
+ type: "string",
2068
+ default: "ai-tasks"
2069
+ },
2070
+ cwd: {
2071
+ type: "string",
2072
+ default: "."
2073
+ },
2074
+ target: {
2075
+ type: "string",
2076
+ multiple: true,
2077
+ default: []
2078
+ },
2079
+ task: {
2080
+ type: "string",
2081
+ multiple: true,
2082
+ default: []
2083
+ }
2084
+ };
2085
+ /**
2086
+ * Parses the arguments of the `ckeditor5-dev-agents` command:
2087
+ *
2088
+ * * `--config <path>`: the directory with `tasks/` and `targets/`. Default: `ai-tasks`.
2089
+ * * `--cwd <path>`: a directory inside the checkout to run in. Default: the current directory.
2090
+ * * `--target <name>`: the targets to run (repeatable or comma-separated). Default: the targets of the checkout.
2091
+ * * `--task <id>`: the tasks to run (repeatable or comma-separated). Default: the tasks each target enables.
2092
+ * * `--publish`: commit and push the state and the fixes, and open or update the report pull requests.
2093
+ *
2094
+ * Paths are relative to the current directory.
2095
+ */
2096
+ function parseArguments(cliArguments, { cwd = process.cwd(), env = process.env } = {}) {
2097
+ const { values } = parseArgs({
2098
+ args: cliArguments,
2099
+ options: {
2100
+ ...COMMON_OPTIONS,
2101
+ publish: {
2102
+ type: "boolean",
2103
+ default: false
2104
+ }
2105
+ }
2106
+ });
2107
+ if (values.publish && !env.AI_TASKS_GITHUB_TOKEN) throw new Error("A published run needs a GitHub token in the \"AI_TASKS_GITHUB_TOKEN\" environment variable.");
2108
+ return {
2109
+ ...resolveCommonArguments(values, cwd),
2110
+ token: values.publish ? env.AI_TASKS_GITHUB_TOKEN : void 0,
2111
+ author: {
2112
+ name: env.AI_TASKS_GIT_NAME || "CKEditor AI Tasks",
2113
+ email: env.AI_TASKS_GIT_EMAIL || "ai-tasks@users.noreply.github.com"
2114
+ },
2115
+ buildUrl: env.AI_TASKS_BUILD_URL
2116
+ };
2117
+ }
2118
+ /**
2119
+ * Parses the arguments of the `reject` and `dismiss` commands:
2120
+ *
2121
+ * * `ckeditor5-dev-agents reject <id> --reason "…"`: the finding is wrong. A decision records why.
2122
+ * * `ckeditor5-dev-agents dismiss <id>`: the finding is dropped without a decision.
2123
+ *
2124
+ * Both accept `--force` (continue when other fixes change the same files), `--config`, `--cwd`, `--target` and `--task`.
2125
+ */
2126
+ function parseResolveArguments(cliArguments, { cwd = process.cwd() } = {}) {
2127
+ const { values, positionals } = parseArgs({
2128
+ args: cliArguments,
2129
+ allowPositionals: true,
2130
+ options: {
2131
+ ...COMMON_OPTIONS,
2132
+ reason: { type: "string" },
2133
+ force: {
2134
+ type: "boolean",
2135
+ default: false
2136
+ }
2137
+ }
2138
+ });
2139
+ const [action, id, ...rest] = positionals;
2140
+ const reason = values.reason?.trim();
2141
+ if (action !== "reject" && action !== "dismiss" || !id || rest.length) throw new Error("Usage: ckeditor5-dev-agents reject <id> --reason \"…\" | ckeditor5-dev-agents dismiss <id>");
2142
+ if (action === "reject" && !reason) throw new Error("Rejecting a finding needs a reason. Pass it with \"--reason\".");
2143
+ if (action === "dismiss" && values.reason) throw new Error("The \"--reason\" argument works only with \"reject\". A dismissed finding has no decision.");
2144
+ return {
2145
+ action,
2146
+ id,
2147
+ reason,
2148
+ force: values.force,
2149
+ ...resolveCommonArguments(values, cwd)
2150
+ };
2151
+ }
2152
+ /**
2153
+ * Runs the `ckeditor5-dev-agents` command. Returns the exit code.
2154
+ */
2155
+ async function runCli(cliArguments) {
2156
+ const args = cliArguments.filter((argument) => argument !== "--");
2157
+ try {
2158
+ if (args[0] === "reject" || args[0] === "dismiss") {
2159
+ console.log(renderResolvedFinding(await resolveFinding(parseResolveArguments(args))));
2160
+ return 0;
2161
+ }
2162
+ const { ok, targets } = await runTasks(parseArguments(args));
2163
+ console.log(`\n${renderConsoleSummary(targets)}`);
2164
+ if (!ok) console.error("\nSome tasks did not finish cleanly. See the problems above.");
2165
+ return ok ? 0 : 1;
2166
+ } catch (error) {
2167
+ console.error(getErrorMessage(error));
2168
+ return 1;
2169
+ }
2170
+ }
2171
+ function resolveCommonArguments(values, cwd) {
2172
+ return {
2173
+ configPath: upath.resolve(cwd, values.config),
2174
+ cwd: upath.resolve(cwd, values.cwd),
2175
+ targets: splitList(values.target),
2176
+ tasks: splitList(values.task)
2177
+ };
2178
+ }
2179
+ /**
2180
+ * CI passes pipeline parameters as one string, so `--target=cs,ckbox` means the same as `--target=cs --target=ckbox`.
2181
+ */
2182
+ function splitList(values) {
2183
+ return [...new Set(values.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean))];
2184
+ }
2185
+ //#endregion
2186
+ export { parseArguments, parseResolveArguments, runCli };