@acme-skunkworks/agent-skills 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +5 -4
  2. package/package.json +2 -6
  3. package/skills/changelog/README.md +59 -0
  4. package/skills/changelog/SKILL.md +187 -0
  5. package/skills/changelog/config.example.json +5 -0
  6. package/skills/changelog/config.json +5 -0
  7. package/skills/changelog/package.json +31 -0
  8. package/skills/changelog/references/changelog-contract.md +121 -0
  9. package/skills/changelog/scripts/add-links.mjs +97 -0
  10. package/skills/changelog/scripts/lib/changelog.mjs +46 -0
  11. package/skills/changelog/scripts/lib/config.mjs +53 -0
  12. package/skills/changelog/scripts/lib/derive-packages.mjs +39 -0
  13. package/skills/changelog/scripts/lib/frontmatter.mjs +369 -0
  14. package/skills/changelog/scripts/preflight-changelog-ci.mjs +152 -0
  15. package/skills/changelog/scripts/set-affected-packages.mjs +99 -0
  16. package/skills/changelog/scripts/validate-changelog.mjs +264 -0
  17. package/skills/linear-sync/README.md +47 -0
  18. package/skills/linear-sync/SKILL.md +115 -0
  19. package/skills/linear-sync/config.example.json +4 -0
  20. package/skills/linear-sync/config.json +4 -0
  21. package/skills/linear-sync/package.json +31 -0
  22. package/skills/preflight/README.md +70 -0
  23. package/skills/preflight/SKILL.md +148 -0
  24. package/skills/preflight/config.example.json +6 -0
  25. package/skills/preflight/package.json +33 -0
  26. package/skills/preflight/scripts/classify-lint.mjs +176 -0
  27. package/skills/preflight/scripts/lib/diff-lines.mjs +83 -0
  28. package/skills/preflight/scripts/lib/paths.mjs +26 -0
  29. package/skills/preflight/scripts/lib/scope.mjs +530 -0
  30. package/skills/preflight/scripts/lint-fix.mjs +78 -0
  31. package/skills/preflight/scripts/preflight.mjs +416 -0
  32. package/skills/send-it/README.md +75 -0
  33. package/skills/send-it/SKILL.md +391 -0
  34. package/skills/send-it/config.example.json +5 -0
  35. package/skills/send-it/config.json +5 -0
  36. package/skills/send-it/package.json +33 -0
  37. package/skills/send-it/scripts/derive-bump.mjs +139 -0
  38. package/skills/triage-pr/README.md +56 -0
  39. package/skills/triage-pr/SKILL.md +291 -0
  40. package/skills/triage-pr/config.json +4 -0
  41. package/skills/triage-pr/package.json +32 -0
  42. package/skills/triage-pr/references/review-discipline.md +73 -0
  43. package/skills/triage-pr/scripts/review-threads.mjs +549 -0
@@ -0,0 +1,530 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Branch-scoped file classification for the preflight skill (originally ASW-282).
4
+ * Shared by preflight.mjs, lint-fix.mjs, and classify-lint.mjs.
5
+ *
6
+ * Repo-specific configuration (linted workspaces + base branch) is auto-detected
7
+ * rather than hardcoded (ASW-305, delivered under ASW-344): workspaces come from
8
+ * `pnpm-workspace.yaml` + each package's `lint` script, and the base branch from
9
+ * `origin/HEAD`. A `preflight.config.json` at the repo root overrides both. This
10
+ * keeps the preflight skill portable — a consuming repo edits at most one small
11
+ * file, and usually none.
12
+ */
13
+ import { spawnSync } from "node:child_process";
14
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
15
+ import { basename, join } from "node:path";
16
+
17
+ const ROOT = process.cwd();
18
+
19
+ const LINTABLE_CODE = /\.(ts|tsx|js|jsx|mjs|cjs)$/i;
20
+ const CODE_CONFIG =
21
+ /(^|\/)(eslint\.config\.[^/]+|tsconfig\.eslint\.json|vite\.config\.[^/]+|package\.json|pnpm-lock\.yaml)$/;
22
+ const ESLINT_RUNNABLE =
23
+ /(^|\/)(eslint\.config\.[^/]+|tsconfig\.eslint\.json|vite\.config\.[^/]+)$/;
24
+ const ROOT_ESLINT_CONFIG = /^eslint\.config\.[^/]+$/;
25
+ const MARKDOWN = /\.(md|mdx)$/i;
26
+ const WORKFLOW = /^\.github\/workflows\/.*\.ya?ml$/i;
27
+ const ACTIONLINT_CONFIG = /^\.github\/actionlint\.yaml$/i;
28
+ // Excludes build output plus installed skill bundles (`.agents/skills/`,
29
+ // `.claude/skills/`): a consumer's vendored skills are third-party content, not
30
+ // the branch's own markdown, so re-linting them would surface noise the author
31
+ // can't fix.
32
+ const MD_IGNORE =
33
+ /(?:^|\/)(?:node_modules|dist|\.astro|\.turbo|\.cache)(?:\/|$)|(?:^|\/)\.agents\/skills\/|(?:^|\/)\.claude\/skills\//;
34
+
35
+ const DEFAULT_BASE_BRANCH = "main";
36
+
37
+ /**
38
+ * Minimal reader for the `packages:` block-sequence in `pnpm-workspace.yaml`.
39
+ * Hand-rolled (no YAML dependency, per ASW-303) so the scripts travel without
40
+ * node_modules. Uses plain string operations rather than a backtracking regex.
41
+ * @param {string} root
42
+ * @returns {string[]}
43
+ */
44
+ function readWorkspaceGlobs(root) {
45
+ const file = join(root, "pnpm-workspace.yaml");
46
+ if (!existsSync(file)) {
47
+ return [];
48
+ }
49
+
50
+ const lines = readFileSync(file, "utf8").split("\n");
51
+ const globs = [];
52
+ let inPackages = false;
53
+
54
+ for (const raw of lines) {
55
+ const line = raw.trimEnd();
56
+ const trimmed = line.trim();
57
+
58
+ if (trimmed === "packages:") {
59
+ inPackages = true;
60
+ continue;
61
+ }
62
+
63
+ if (!inPackages) {
64
+ continue;
65
+ }
66
+
67
+ // A new top-level key (non-indented, non-list) ends the packages block.
68
+ if (line.length > 0 && !line.startsWith(" ") && !line.startsWith("-")) {
69
+ break;
70
+ }
71
+
72
+ if (trimmed.startsWith("-")) {
73
+ const value = trimmed
74
+ .slice(1)
75
+ .trim()
76
+ .replace(/^['"]|['"]$/g, "");
77
+ if (value) {
78
+ globs.push(value);
79
+ }
80
+ }
81
+ }
82
+
83
+ return globs;
84
+ }
85
+
86
+ /**
87
+ * Whether a directory entry resolves to a directory, following symlinks.
88
+ * `Dirent.isDirectory()` is false for a symlink-to-directory, so a workspace
89
+ * that is a symlink (monorepo linking tools, vendored packages) would otherwise
90
+ * be silently skipped. Broken symlinks resolve to false.
91
+ * @param {string} parentPath
92
+ * @param {import('node:fs').Dirent} entry
93
+ * @returns {boolean}
94
+ */
95
+ function entryIsDirectory(parentPath, entry) {
96
+ if (entry.isDirectory()) {
97
+ return true;
98
+ }
99
+
100
+ if (entry.isSymbolicLink()) {
101
+ try {
102
+ return statSync(join(parentPath, entry.name)).isDirectory();
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ return false;
109
+ }
110
+
111
+ /**
112
+ * Expand a workspace glob to existing directories. Supports the common
113
+ * single-level trailing `*` (e.g. `apps/*`) and a literal directory path. Nested
114
+ * `**` globs are not expanded — document a `preflight.config.json` override if a
115
+ * repo needs them.
116
+ * @param {string} root
117
+ * @param {string} glob
118
+ * @returns {string[]}
119
+ */
120
+ function expandGlob(root, glob) {
121
+ if (glob.endsWith("/*")) {
122
+ const parent = glob.slice(0, -2);
123
+ const parentPath = join(root, parent);
124
+ if (!existsSync(parentPath)) {
125
+ return [];
126
+ }
127
+
128
+ return readdirSync(parentPath, { withFileTypes: true })
129
+ .filter((entry) => entryIsDirectory(parentPath, entry))
130
+ .map((entry) => `${parent}/${entry.name}`);
131
+ }
132
+
133
+ // Skip more complex globs (e.g. nested `**`); only literal dirs fall through.
134
+ if (glob.includes("*")) {
135
+ return [];
136
+ }
137
+
138
+ return existsSync(join(root, glob)) ? [glob] : [];
139
+ }
140
+
141
+ /**
142
+ * Auto-detect linted workspaces from `pnpm-workspace.yaml`. A workspace is
143
+ * included only if its `package.json` declares a `lint` script — this naturally
144
+ * excludes intentionally-unlinted workspaces and non-package dirs without a
145
+ * hand-maintained omission list.
146
+ * @param {string} [root]
147
+ * @returns {Record<string, { filter: string; prefix: string }>}
148
+ */
149
+ export function detectWorkspaces(root = ROOT) {
150
+ const dirs = readWorkspaceGlobs(root).flatMap((glob) =>
151
+ expandGlob(root, glob),
152
+ );
153
+ /** @type {Record<string, { filter: string; prefix: string }>} */
154
+ const workspaces = {};
155
+
156
+ for (const dir of dirs) {
157
+ const pkgPath = join(root, dir, "package.json");
158
+ if (!existsSync(pkgPath)) {
159
+ continue;
160
+ }
161
+
162
+ let pkg;
163
+ try {
164
+ pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
165
+ } catch {
166
+ continue;
167
+ }
168
+
169
+ if (!pkg.name || !pkg.scripts || !pkg.scripts.lint) {
170
+ continue;
171
+ }
172
+
173
+ // Key by basename for readable summary output. On the rare collision (two
174
+ // workspaces sharing a basename across glob roots, e.g. apps/shared and
175
+ // packages/shared) fall back to the full path so neither is silently dropped.
176
+ let key = basename(dir);
177
+ if (Object.prototype.hasOwnProperty.call(workspaces, key)) {
178
+ console.warn(
179
+ `preflight: workspace basename collision for "${key}" — keying "${dir}" by its full path`,
180
+ );
181
+ key = dir;
182
+ }
183
+
184
+ workspaces[key] = { filter: pkg.name, prefix: `${dir}/` };
185
+ }
186
+
187
+ return workspaces;
188
+ }
189
+
190
+ /**
191
+ * Resolve the base branch to diff against. Detects the default branch from
192
+ * `origin/HEAD` (e.g. `main`, `master`, `develop`); falls back to `main` when the
193
+ * symbolic ref is absent (common on fresh clones / shallow CI checkouts).
194
+ * @param {string} [root]
195
+ * @returns {string}
196
+ */
197
+ export function detectBaseBranch(root = ROOT) {
198
+ const result = spawnSync(
199
+ "git",
200
+ ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
201
+ { cwd: root, encoding: "utf8" },
202
+ );
203
+ if (result.status === 0 && result.stdout.trim()) {
204
+ return result.stdout.trim().replace(/^refs\/remotes\/origin\//, "");
205
+ }
206
+
207
+ return DEFAULT_BASE_BRANCH;
208
+ }
209
+
210
+ /**
211
+ * Validate a parsed `preflight.config.json` shape, dropping any malformed key
212
+ * (with a warning) so it falls back to auto-detection rather than surfacing a
213
+ * confusing downstream error. `baseBranch` must be a non-empty string;
214
+ * `workspaces` must be an object of `{ filter, prefix }` string pairs.
215
+ * @param {unknown} raw
216
+ * @returns {{ baseBranch?: string; workspaces?: Record<string, { filter: string; prefix: string }> }}
217
+ */
218
+ function validateOverride(raw) {
219
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
220
+ console.warn(
221
+ "preflight: ignoring preflight.config.json (expected a JSON object)",
222
+ );
223
+ return {};
224
+ }
225
+
226
+ /** @type {{ baseBranch?: string; workspaces?: Record<string, { filter: string; prefix: string }> }} */
227
+ const override = {};
228
+
229
+ if ("baseBranch" in raw) {
230
+ const baseBranch =
231
+ typeof raw.baseBranch === "string" ? raw.baseBranch.trim() : "";
232
+ if (baseBranch) {
233
+ override.baseBranch = baseBranch;
234
+ } else {
235
+ console.warn(
236
+ "preflight: ignoring preflight.config.json baseBranch (expected a non-empty string)",
237
+ );
238
+ }
239
+ }
240
+
241
+ if ("workspaces" in raw) {
242
+ const ws = raw.workspaces;
243
+ if (typeof ws !== "object" || ws === null || Array.isArray(ws)) {
244
+ console.warn(
245
+ "preflight: ignoring preflight.config.json workspaces (expected an object)",
246
+ );
247
+ } else {
248
+ /** @type {Record<string, { filter: string; prefix: string }>} */
249
+ const workspaces = {};
250
+ for (const [key, value] of Object.entries(ws)) {
251
+ const entry = typeof value === "object" && value !== null ? value : {};
252
+ const filter =
253
+ typeof entry.filter === "string" ? entry.filter.trim() : "";
254
+ const prefix =
255
+ typeof entry.prefix === "string" ? entry.prefix.trim() : "";
256
+
257
+ // Reject empty filter/prefix and normalise prefix to a trailing slash:
258
+ // `classifyChangedFiles` uses `file.startsWith(prefix)`, so an empty
259
+ // prefix would bucket every file and a slash-less one would over-match
260
+ // sibling directories (apps/web vs apps/website).
261
+ if (filter && prefix) {
262
+ workspaces[key] = {
263
+ filter,
264
+ prefix: prefix.endsWith("/") ? prefix : `${prefix}/`,
265
+ };
266
+ } else {
267
+ console.warn(
268
+ `preflight: ignoring preflight.config.json workspace "${key}" (expected non-empty { filter, prefix } strings)`,
269
+ );
270
+ }
271
+ }
272
+
273
+ // Only treat the override as authoritative when at least one entry
274
+ // survived validation. An all-invalid block must NOT win over
275
+ // auto-detection: `resolveConfig` uses `override.workspaces ??
276
+ // detectWorkspaces(root)`, and an empty `{}` is truthy, so it would
277
+ // otherwise silently run ESLint on zero workspaces.
278
+ if (Object.keys(workspaces).length > 0) {
279
+ override.workspaces = workspaces;
280
+ } else {
281
+ console.warn(
282
+ "preflight: ignoring preflight.config.json workspaces (no valid entries; falling back to auto-detection)",
283
+ );
284
+ }
285
+ }
286
+ }
287
+
288
+ return override;
289
+ }
290
+
291
+ /**
292
+ * Load and validate an optional `preflight.config.json` override from the repo
293
+ * root. Either key may be supplied independently.
294
+ * @param {string} root
295
+ * @returns {{ baseBranch?: string; workspaces?: Record<string, { filter: string; prefix: string }> }}
296
+ */
297
+ function loadConfigOverride(root) {
298
+ const file = join(root, "preflight.config.json");
299
+ if (!existsSync(file)) {
300
+ return {};
301
+ }
302
+
303
+ let parsed;
304
+ try {
305
+ parsed = JSON.parse(readFileSync(file, "utf8"));
306
+ } catch {
307
+ console.warn(
308
+ "preflight: ignoring malformed preflight.config.json (could not parse JSON)",
309
+ );
310
+ return {};
311
+ }
312
+
313
+ return validateOverride(parsed);
314
+ }
315
+
316
+ /** @type {{ baseBranch: string; workspaces: Record<string, { filter: string; prefix: string }> } | null} */
317
+ let cachedConfig = null;
318
+
319
+ /**
320
+ * Resolve preflight configuration: an explicit `preflight.config.json` wins,
321
+ * otherwise auto-detect. Memoised for the default root.
322
+ * @param {string} [root]
323
+ * @returns {{ baseBranch: string; workspaces: Record<string, { filter: string; prefix: string }> }}
324
+ */
325
+ export function resolveConfig(root = ROOT) {
326
+ if (root === ROOT && cachedConfig) {
327
+ return cachedConfig;
328
+ }
329
+
330
+ const override = loadConfigOverride(root);
331
+ const config = {
332
+ baseBranch: override.baseBranch ?? detectBaseBranch(root),
333
+ workspaces: override.workspaces ?? detectWorkspaces(root),
334
+ };
335
+
336
+ if (root === ROOT) {
337
+ cachedConfig = config;
338
+ }
339
+
340
+ return config;
341
+ }
342
+
343
+ /**
344
+ * @returns {string}
345
+ */
346
+ export function gitMergeBase() {
347
+ const { baseBranch } = resolveConfig();
348
+ const result = spawnSync(
349
+ "git",
350
+ ["merge-base", "HEAD", `origin/${baseBranch}`],
351
+ { cwd: ROOT, encoding: "utf8" },
352
+ );
353
+ if (result.status !== 0) {
354
+ throw new Error(
355
+ `preflight: could not find merge base with origin/${baseBranch}. Run: git fetch origin ${baseBranch}`,
356
+ );
357
+ }
358
+
359
+ return result.stdout.trim();
360
+ }
361
+
362
+ /**
363
+ * Branch-changed files that still exist at HEAD. `--diff-filter=d` excludes
364
+ * deletions: a file removed on the branch can't be linted and must not reach
365
+ * ESLint/markdownlint (which error on a missing pattern), nor be classified for
366
+ * violations.
367
+ * @param {string} mergeBase
368
+ * @returns {string[]}
369
+ */
370
+ export function gitChangedFiles(mergeBase) {
371
+ const result = spawnSync(
372
+ "git",
373
+ ["diff", "--name-only", "--diff-filter=d", `${mergeBase}...HEAD`],
374
+ { cwd: ROOT, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 },
375
+ );
376
+ if (result.error || result.status !== 0) {
377
+ const detail =
378
+ result.error?.message || result.stderr?.trim() || "unknown git diff error";
379
+ throw new Error(`preflight: git diff failed: ${detail}`);
380
+ }
381
+
382
+ return result.stdout.split("\n").filter(Boolean);
383
+ }
384
+
385
+ /**
386
+ * @param {string} file
387
+ */
388
+ function isLintableCodePath(file) {
389
+ return LINTABLE_CODE.test(file) || CODE_CONFIG.test(file);
390
+ }
391
+
392
+ /**
393
+ * Paths ESLint can actually lint (excludes pnpm-lock.yaml and other non-runnable gates).
394
+ * @param {string} file
395
+ */
396
+ function isEslintRunnablePath(file) {
397
+ return LINTABLE_CODE.test(file) || ESLINT_RUNNABLE.test(file);
398
+ }
399
+
400
+ /**
401
+ * @param {boolean} workflowsChanged
402
+ * @param {string[]} workflows
403
+ */
404
+ function resolveActionlintTargets(workflowsChanged, workflows) {
405
+ if (!workflowsChanged) {
406
+ return [];
407
+ }
408
+
409
+ if (workflows.length > 0) {
410
+ return [...workflows];
411
+ }
412
+
413
+ // actionlint config-only change: re-validate all tracked workflows (mirrors CI path trigger)
414
+ const ls = spawnSync("git", ["ls-files", ".github/workflows"], {
415
+ cwd: ROOT,
416
+ encoding: "utf8",
417
+ maxBuffer: 10 * 1024 * 1024,
418
+ });
419
+ if (ls.status !== 0) {
420
+ return [];
421
+ }
422
+
423
+ return ls.stdout.split("\n").filter((file) => WORKFLOW.test(file));
424
+ }
425
+
426
+ /**
427
+ * @param {string[]} changedFiles
428
+ * @param {Record<string, { filter: string; prefix: string }>} [workspaces]
429
+ */
430
+ export function classifyChangedFiles(
431
+ changedFiles,
432
+ workspaces = resolveConfig().workspaces,
433
+ ) {
434
+ let codeChanged = false;
435
+ let markdownChanged = false;
436
+ let workflowsChanged = false;
437
+
438
+ /** @type {Record<string, string[]>} */
439
+ const eslint = {
440
+ scripts: [],
441
+ root: [],
442
+ ...Object.fromEntries(Object.keys(workspaces).map((k) => [k, []])),
443
+ };
444
+ const markdown = [];
445
+ const workflows = [];
446
+
447
+ for (const file of changedFiles) {
448
+ if (WORKFLOW.test(file) || ACTIONLINT_CONFIG.test(file)) {
449
+ workflowsChanged = true;
450
+ if (WORKFLOW.test(file)) {
451
+ workflows.push(file);
452
+ }
453
+ }
454
+
455
+ if (MARKDOWN.test(file) && !MD_IGNORE.test(file)) {
456
+ markdownChanged = true;
457
+ markdown.push(file);
458
+ }
459
+
460
+ if (isLintableCodePath(file)) {
461
+ codeChanged = true;
462
+ }
463
+
464
+ if (!isEslintRunnablePath(file)) {
465
+ continue;
466
+ }
467
+
468
+ if (file.startsWith("scripts/")) {
469
+ eslint.scripts.push(file);
470
+ } else if (
471
+ ROOT_ESLINT_CONFIG.test(file) ||
472
+ file === "tsconfig.eslint.json"
473
+ ) {
474
+ eslint.root.push(file);
475
+ } else {
476
+ for (const [key, { prefix }] of Object.entries(workspaces)) {
477
+ if (file.startsWith(prefix)) {
478
+ eslint[key].push(file);
479
+ }
480
+ }
481
+ }
482
+ }
483
+
484
+ const actionlintTargets = resolveActionlintTargets(
485
+ workflowsChanged,
486
+ workflows,
487
+ );
488
+
489
+ return {
490
+ codeChanged,
491
+ markdownChanged,
492
+ workflowsChanged,
493
+ eslint,
494
+ markdown,
495
+ workflows,
496
+ actionlintTargets,
497
+ changedFiles,
498
+ };
499
+ }
500
+
501
+ /**
502
+ * Strip a workspace prefix from repo-root-relative paths so they can be handed
503
+ * to a workspace-scoped tool. `pnpm --filter <pkg> exec <tool>` runs with the
504
+ * workspace directory as cwd, so a path like `apps/studio/src/x.ts` must become
505
+ * `src/x.ts` for the tool to resolve it. Paths that don't start with `prefix`
506
+ * (or when `prefix` is empty) are returned unchanged.
507
+ * @param {string[]} files repo-root-relative paths
508
+ * @param {string} prefix workspace prefix with a trailing slash (e.g. "apps/studio/")
509
+ * @returns {string[]} paths relative to the workspace directory
510
+ */
511
+ export function relativiseToWorkspace(files, prefix) {
512
+ if (!prefix) {
513
+ return [...files];
514
+ }
515
+
516
+ return files.map((file) =>
517
+ file.startsWith(prefix) ? file.slice(prefix.length) : file,
518
+ );
519
+ }
520
+
521
+ /**
522
+ * @returns {{ mergeBase: string; workspaces: Record<string, { filter: string; prefix: string }>; codeChanged: boolean; markdownChanged: boolean; workflowsChanged: boolean; eslint: object; markdown: string[]; workflows: string[]; actionlintTargets: string[]; changedFiles: string[] }}
523
+ */
524
+ export function getBranchScope() {
525
+ const { workspaces } = resolveConfig();
526
+ const mergeBase = gitMergeBase();
527
+ const changedFiles = gitChangedFiles(mergeBase);
528
+ const classified = classifyChangedFiles(changedFiles, workspaces);
529
+ return { mergeBase, workspaces, ...classified };
530
+ }
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ import { getBranchScope } from "./lib/scope.mjs";
3
+ /**
4
+ * Scoped auto-fix for branch-changed lintable paths (originally ASW-282).
5
+ */
6
+ import { spawnSync } from "node:child_process";
7
+
8
+ const ROOT = process.cwd();
9
+
10
+ /**
11
+ * @param {string} cmd
12
+ * @param {string[]} argv
13
+ */
14
+ function run(cmd, argv) {
15
+ const result = spawnSync(cmd, argv, {
16
+ cwd: ROOT,
17
+ encoding: "utf8",
18
+ stdio: "inherit",
19
+ });
20
+ if (result.status !== 0) {
21
+ process.exit(result.status ?? 1);
22
+ }
23
+ }
24
+
25
+ function main() {
26
+ const scope = getBranchScope();
27
+
28
+ if (!scope.codeChanged && !scope.markdownChanged) {
29
+ console.log(
30
+ "preflight-lint-fix: nothing to fix (no code or markdown changes on branch)",
31
+ );
32
+ return;
33
+ }
34
+
35
+ if (scope.codeChanged) {
36
+ const scriptFiles = [...scope.eslint.scripts, ...scope.eslint.root];
37
+ if (scriptFiles.length > 0) {
38
+ console.log(
39
+ `preflight-lint-fix: eslint --fix on ${scriptFiles.length} root/scripts file(s)`,
40
+ );
41
+ run("pnpm", ["exec", "eslint", "--fix", "--", ...scriptFiles]);
42
+ }
43
+
44
+ for (const [key, { filter }] of Object.entries(scope.workspaces)) {
45
+ const files = scope.eslint[key];
46
+ if (files.length === 0) {
47
+ continue;
48
+ }
49
+
50
+ console.log(
51
+ `preflight-lint-fix: eslint --fix (${filter}) on ${files.length} file(s)`,
52
+ );
53
+ run("pnpm", [
54
+ "--filter",
55
+ filter,
56
+ "exec",
57
+ "eslint",
58
+ "--fix",
59
+ "--",
60
+ ...files,
61
+ ]);
62
+ }
63
+ }
64
+
65
+ if (scope.markdownChanged && scope.markdown.length > 0) {
66
+ console.log(
67
+ `preflight-lint-fix: markdownlint --fix on ${scope.markdown.length} file(s)`,
68
+ );
69
+ // No explicit --config: markdownlint-cli2 auto-discovers the consumer repo's
70
+ // config (`.markdownlint-cli2.*` / `.markdownlint.*`), matching how the
71
+ // detection side (preflight.mjs) invokes it. Keeps the skill portable.
72
+ run("pnpm", ["exec", "markdownlint-cli2", "--fix", ...scope.markdown]);
73
+ }
74
+
75
+ console.log("preflight-lint-fix: done");
76
+ }
77
+
78
+ main();