@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,264 @@
1
+ #!/usr/bin/env node
2
+ import { parseFrontmatter } from "./lib/frontmatter.mjs";
3
+ import { readdirSync, readFileSync, statSync } from "node:fs";
4
+ import { join, basename } from "node:path";
5
+
6
+ const CHANGELOG_DIR = "changelog";
7
+ const FILENAME_RE = /^(\d{8})-(\d{6})-([a-z0-9-]+)\.md$/;
8
+ const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
9
+ const SHA7_RE = /^[0-9a-f]{7}$/;
10
+ const ISSUE_RE = /^[A-Z]{2,}-\d+$/;
11
+ const CATEGORIES = new Set([
12
+ "chore",
13
+ "docs",
14
+ "feature",
15
+ "fix",
16
+ "perf",
17
+ "refactor",
18
+ ]);
19
+ const MERGE_STRATEGIES = new Set(["merge", "rebase", "squash"]);
20
+ const SECTION_RE = /^##\s+(Breaking|Added|Changed|Fixed)\b/m;
21
+ const BREAKING_RE = /^##\s+Breaking\b/m;
22
+
23
+ const REQUIRED = [
24
+ "title",
25
+ "created_at",
26
+ "branch",
27
+ "author",
28
+ "category",
29
+ "breaking",
30
+ "co_authors",
31
+ ];
32
+
33
+ const errors = [];
34
+ function fail(file, msg) {
35
+ errors.push(`${file}: ${msg}`);
36
+ }
37
+
38
+ function isInt(v) {
39
+ return typeof v === "number" && Number.isInteger(v);
40
+ }
41
+
42
+ function isNonNegInt(v) {
43
+ return isInt(v) && v >= 0;
44
+ }
45
+
46
+ function isStringArray(v) {
47
+ return Array.isArray(v) && v.every((x) => typeof x === "string");
48
+ }
49
+
50
+ function validateEntry(file, raw) {
51
+ const name = basename(file);
52
+
53
+ if (!FILENAME_RE.test(name)) {
54
+ fail(
55
+ file,
56
+ "filename must match YYYYMMDD-HHMMSS-<slug>.md (slug: [a-z0-9-]+)",
57
+ );
58
+ return;
59
+ }
60
+
61
+ let parsed;
62
+ try {
63
+ parsed = parseFrontmatter(raw);
64
+ } catch (error) {
65
+ fail(file, `frontmatter unparseable: ${error.message}`);
66
+ return;
67
+ }
68
+
69
+ const fm = parsed.data ?? {};
70
+ const body = parsed.content ?? "";
71
+
72
+ for (const key of REQUIRED) {
73
+ if (!(key in fm)) {
74
+ fail(file, `missing required field: ${key}`);
75
+ }
76
+ }
77
+
78
+ if (
79
+ "title" in fm &&
80
+ (typeof fm.title !== "string" || fm.title.trim() === "")
81
+ ) {
82
+ fail(file, "title must be a non-empty string");
83
+ }
84
+
85
+ if (
86
+ "release_note" in fm &&
87
+ fm.release_note !== null &&
88
+ typeof fm.release_note !== "string"
89
+ ) {
90
+ fail(file, "release_note must be a string or null when present");
91
+ }
92
+
93
+ if ("created_at" in fm) {
94
+ const v =
95
+ typeof fm.created_at === "string"
96
+ ? fm.created_at
97
+ : (fm.created_at?.toISOString?.() ?? "");
98
+ if (!ISO_UTC_RE.test(v)) {
99
+ fail(
100
+ file,
101
+ `created_at must be ISO 8601 UTC with Z suffix (got ${JSON.stringify(fm.created_at)})`,
102
+ );
103
+ }
104
+ }
105
+
106
+ if (fm.merged_at != null && fm.merged_at !== "") {
107
+ const v =
108
+ typeof fm.merged_at === "string"
109
+ ? fm.merged_at
110
+ : (fm.merged_at?.toISOString?.() ?? "");
111
+ if (!ISO_UTC_RE.test(v)) {
112
+ fail(file, "merged_at must be ISO 8601 UTC with Z suffix when set");
113
+ }
114
+ }
115
+
116
+ if (
117
+ "branch" in fm &&
118
+ (typeof fm.branch !== "string" || fm.branch.trim() === "")
119
+ ) {
120
+ fail(file, "branch must be a non-empty string");
121
+ }
122
+
123
+ if (fm.pr != null && fm.pr !== "" && (!isInt(fm.pr) || Number(fm.pr) <= 0)) {
124
+ fail(file, "pr must be a positive integer when set");
125
+ }
126
+
127
+ if (fm.commit != null && fm.commit !== "" && !SHA7_RE.test(fm.commit)) {
128
+ fail(file, "commit must be a 7-char hex SHA when set");
129
+ }
130
+
131
+ if (
132
+ fm.merge_strategy != null &&
133
+ fm.merge_strategy !== "" &&
134
+ !MERGE_STRATEGIES.has(fm.merge_strategy)
135
+ ) {
136
+ fail(
137
+ file,
138
+ `merge_strategy must be one of: ${[...MERGE_STRATEGIES].join(", ")}`,
139
+ );
140
+ }
141
+
142
+ if (
143
+ "author" in fm &&
144
+ (typeof fm.author !== "string" || fm.author.trim() === "")
145
+ ) {
146
+ fail(file, "author must be a non-empty string");
147
+ }
148
+
149
+ if ("co_authors" in fm && !isStringArray(fm.co_authors)) {
150
+ fail(file, "co_authors must be an array of strings (use [] when none)");
151
+ }
152
+
153
+ if ("category" in fm && !CATEGORIES.has(fm.category)) {
154
+ fail(file, `category must be one of: ${[...CATEGORIES].join(", ")}`);
155
+ }
156
+
157
+ if ("breaking" in fm && typeof fm.breaking !== "boolean") {
158
+ fail(file, "breaking must be a boolean");
159
+ }
160
+
161
+ if ("issues" in fm) {
162
+ if (isStringArray(fm.issues)) {
163
+ for (const id of fm.issues) {
164
+ if (!ISSUE_RE.test(id)) {
165
+ fail(
166
+ file,
167
+ `issues entry ${JSON.stringify(id)} must match [A-Z]{2,}-\\d+`,
168
+ );
169
+ }
170
+ }
171
+ } else {
172
+ fail(file, "issues must be an array of strings when present");
173
+ }
174
+ }
175
+
176
+ // affected_packages is owned by the post-merge enrich step. The author emits
177
+ // an empty array as a placeholder; the enrich step overwrites it with the
178
+ // canonical list derived from PR files. Only enforce structure (string array).
179
+ if (
180
+ "affected_packages" in fm &&
181
+ fm.affected_packages != null &&
182
+ !isStringArray(fm.affected_packages)
183
+ ) {
184
+ fail(
185
+ file,
186
+ "affected_packages must be an array of strings (use [] when unpopulated)",
187
+ );
188
+ }
189
+
190
+ // PR stats live under stats: { files_changed, loc_added, loc_removed }.
191
+ const statKeys = ["files_changed", "loc_added", "loc_removed"];
192
+ for (const k of statKeys) {
193
+ if (k in fm) {
194
+ fail(file, `${k} must be under stats, not top-level`);
195
+ }
196
+ }
197
+
198
+ if (!("stats" in fm) || fm.stats == null) {
199
+ fail(file, "missing required field: stats");
200
+ } else if (typeof fm.stats !== "object" || Array.isArray(fm.stats)) {
201
+ fail(file, "stats must be an object");
202
+ } else {
203
+ for (const k of statKeys) {
204
+ if (
205
+ k in fm.stats &&
206
+ fm.stats[k] != null &&
207
+ fm.stats[k] !== "" &&
208
+ !isNonNegInt(fm.stats[k])
209
+ ) {
210
+ fail(file, `stats.${k} must be a non-negative integer when set`);
211
+ }
212
+ }
213
+ }
214
+
215
+ if (fm.breaking === true && !BREAKING_RE.test(body)) {
216
+ fail(file, 'breaking: true requires a "## Breaking" section in the body');
217
+ }
218
+
219
+ if (!SECTION_RE.test(body)) {
220
+ fail(
221
+ file,
222
+ "body must contain at least one of: ## Breaking | ## Added | ## Changed | ## Fixed",
223
+ );
224
+ }
225
+ }
226
+
227
+ function listEntries() {
228
+ let stat;
229
+ try {
230
+ stat = statSync(CHANGELOG_DIR);
231
+ } catch {
232
+ console.error(`changelog directory not found: ${CHANGELOG_DIR}`);
233
+ process.exit(2);
234
+ }
235
+
236
+ if (!stat.isDirectory()) {
237
+ console.error(`${CHANGELOG_DIR} is not a directory`);
238
+ process.exit(2);
239
+ }
240
+
241
+ return readdirSync(CHANGELOG_DIR)
242
+ .filter((n) => n.endsWith(".md") && n !== "README.md")
243
+ .map((n) => join(CHANGELOG_DIR, n));
244
+ }
245
+
246
+ const entries = listEntries();
247
+ for (const file of entries) {
248
+ validateEntry(file, readFileSync(file, "utf8"));
249
+ }
250
+
251
+ if (errors.length > 0) {
252
+ console.error(
253
+ `Changelog validation failed with ${errors.length} error(s):\n`,
254
+ );
255
+ for (const e of errors) {
256
+ console.error(` - ${e}`);
257
+ }
258
+
259
+ process.exit(1);
260
+ }
261
+
262
+ console.log(
263
+ `Changelog validation passed (${entries.length} entr${entries.length === 1 ? "y" : "ies"} checked).`,
264
+ );
@@ -0,0 +1,47 @@
1
+ # linear-sync
2
+
3
+ Transition the Linear issues linked to the current branch through their workflow
4
+ states (In Progress / In Review / Done) — resolving state IDs by team **name**,
5
+ extracting issue IDs from the branch, and applying the transition idempotently.
6
+
7
+ ## Install
8
+
9
+ From any consumer repo:
10
+
11
+ ```bash
12
+ npx skills add https://github.com/acme-skunkworks/agent-skills --skill linear-sync --agent claude-code --agent cursor --copy
13
+ ```
14
+
15
+ `--copy` writes real files so the bundle is portable. Don't use `-g` / `--global`
16
+ — the install should live in the consumer repo.
17
+
18
+ ## Configure
19
+
20
+ The shipped [`config.json`](config.json) carries **ACME Skunkworks defaults**
21
+ (`linearTeamName` and `issueKeys`) — update them for your organisation on install,
22
+ or the state lookups will target the wrong team and branch issue-IDs won't match.
23
+ A neutral [`config.example.json`](config.example.json) ships alongside it as a
24
+ template — copy it over `config.json` and fill in your values, or edit
25
+ `config.json` directly.
26
+
27
+ | Key | Meaning | Default |
28
+ | --- | --- | --- |
29
+ | `linearTeamName` | Linear team **name** used to resolve live state IDs. Stable across team-key renames — always resolve by name, not key. | `"ACME Skunkworks"` |
30
+ | `issueKeys` | Team-key prefixes that may appear in branch names; the issue-ID regex is built from these. Keep legacy keys so old branches still match. | `["ASW", "AKW", "SKW"]` |
31
+
32
+ ## Requirements
33
+
34
+ - The Linear MCP server (the `mcp__linear-server__*` tools). The skill drives it
35
+ directly and has no non-MCP fallback — if it is unavailable, the skill cannot run.
36
+ - The `git` CLI, to read the current branch name.
37
+
38
+ ## What it does
39
+
40
+ Resolves the target state's live ID once (by team name), extracts the branch's
41
+ issue IDs (from `issueKeys`), reads each issue's current state, and applies the
42
+ target transition idempotently — skipping any issue already at or past it. The
43
+ default standalone target is **In Progress** (the start-of-work transition).
44
+
45
+ See [`SKILL.md`](SKILL.md) for the full transition-rules table, the
46
+ team-name-not-key gotcha, and the caller-responsibility (when/whether to fire)
47
+ boundaries.
@@ -0,0 +1,115 @@
1
+ ---
2
+ name: linear-sync
3
+ description: >-
4
+ Transition the Linear issues linked to the current branch through their
5
+ workflow states (In Progress / In Review / Done) — resolve live state IDs by
6
+ team name, extract issue IDs from the branch, and apply the transition
7
+ idempotently. Use when starting work on an issue, when a PR opens or updates,
8
+ during branch cleanup, or whenever a branch's Linear issues need their state
9
+ synced. Resolves state IDs by team name (not key — keys go stale on rename),
10
+ reads the team name and issue-ID prefixes from config.json, and skips any issue
11
+ already at or past the target state.
12
+ license: MIT
13
+ compatibility: >-
14
+ Requires the Linear MCP server (the `mcp__linear-server__*` tools). The branch
15
+ read needs the `git` CLI. If the Linear MCP server is unavailable the skill
16
+ cannot run — it has no non-MCP fallback.
17
+ metadata:
18
+ version: 0.1.1
19
+ allowed-tools: Bash(git:*), mcp__linear-server__get_issue, mcp__linear-server__save_issue, mcp__linear-server__list_issue_statuses
20
+ ---
21
+
22
+ # linear-sync
23
+
24
+ Move the Linear issues linked to the current branch through their workflow
25
+ states. This skill is the single source of truth for **how** issues are
26
+ transitioned: resolving the live state IDs, extracting issue IDs from a branch
27
+ name, and the per-state transition rules. Callers decide **when** and **whether**
28
+ to fire it; the mechanics live here once so the rules don't drift across the
29
+ ship flow, branch cleanup, and the start-of-work transition.
30
+
31
+ ## Configuration
32
+
33
+ Two knobs live in [`config.json`](config.json) beside this file. Read it at the
34
+ start of a run and use its values throughout. Edit your copied `config.json` to
35
+ match the consuming repo:
36
+
37
+ | Key | Meaning | Default |
38
+ | --- | --- | --- |
39
+ | `linearTeamName` | Linear team **name** used to resolve the live state IDs. Use the name, not the key — the key is renamed over time but the name is stable. | `"ACME Skunkworks"` |
40
+ | `issueKeys` | Team-key prefixes that may appear in branch names. The issue-ID regex is built from these. Keep legacy keys so old branches still match. | `["ASW", "AKW", "SKW"]` |
41
+
42
+ A neutral [`config.example.json`](config.example.json) ships alongside it as a
43
+ template — copy it over `config.json` and fill in your values, or edit
44
+ `config.json` directly.
45
+
46
+ ## Resolving state IDs (do this once per run)
47
+
48
+ Call `mcp__linear-server__list_issue_statuses` with `team: <linearTeamName>`
49
+ **once** to resolve the live state IDs for the target state(s).
50
+
51
+ **Pass the team _name_, not the key.** Linear state IDs are per-team, and a
52
+ workspace's team can be renamed over its lifetime (e.g. CAT → WTF → AKW → ASW),
53
+ so a hardcoded key goes stale. The team _name_ (`linearTeamName`) does not move.
54
+ This is the canonical gotcha for adopters — resolve by name, every run.
55
+
56
+ ## Extracting issue IDs from the branch
57
+
58
+ Build the issue-ID regex by joining `issueKeys` with `|`:
59
+ `\b((?:ASW|AKW|SKW)-\d+)\b` for the defaults above. Match it against the
60
+ **upper-cased** branch name — branches like `asw-7-as-acquired` carry the key in
61
+ lower case, and a flow such as `--issue=ASW-7` produces upper-case branch names
62
+ like `ASW-7-as-acquired`. Keeping the legacy keys means leftover branches from
63
+ before a team-key rename are still recognised. Deduplicate the matches. Bogus or
64
+ malformed IDs simply error on lookup and are skipped with a warning — no separate
65
+ validation pass.
66
+
67
+ When a caller already has an `issues` list to hand (e.g. a changelog step emits
68
+ one), use that instead of re-extracting.
69
+
70
+ ## Transition rules
71
+
72
+ For each issue ID, call `mcp__linear-server__get_issue` to read its current
73
+ state, then apply the rule for the target transition. All transitions are
74
+ **idempotent** — an issue already at or past the target state is skipped
75
+ silently.
76
+
77
+ | Target | Apply when current state is … | Skip when current state is … | Fired by |
78
+ | --------------- | ------------------------------------------ | ----------------------------------------------------------- | ----------------------------------- |
79
+ | **In Progress** | `Triage`, `Backlog`, `Todo` | `In Progress`, `In Review`, `Done`, `Canceled`, `Duplicate` | Starting work on an issue |
80
+ | **In Review** | `Triage`, `Backlog`, `Todo`, `In Progress` | `In Review`, `Done`, `Canceled`, `Duplicate` | PR open/update (a ship flow) |
81
+ | **Done** | `Triage`, `Backlog`, `Todo`, `In Progress`, `In Review` | `Done`, `Canceled`, `Duplicate` | Branch cleanup |
82
+
83
+ Apply a transition by calling `mcp__linear-server__save_issue` with
84
+ `state: "<target>"` (or the resolved state ID).
85
+
86
+ > `Canceled` is the Linear API's own US spelling — keep it as-is when referenced
87
+ > in code or config.
88
+
89
+ ## Caller responsibilities (when / whether to fire)
90
+
91
+ The skill owns the mechanics; each caller owns the policy:
92
+
93
+ - **Start of work** — transition to `In Progress` when work begins on an issue
94
+ (unless already In Progress or further along). Run automatically; no prompt.
95
+ - **Ship flow** (PR open/update) — transition linked issues to `In Review`
96
+ automatically after the PR is created or updated. No prompt.
97
+ - **Branch cleanup** — transition orphaned issues to `Done` only **after explicit
98
+ confirmation, default no**. Linear's GitHub integration normally handles the
99
+ `Done` transition on PR merge, so this prompt exists only for the rare case
100
+ where the integration didn't fire (e.g. the issue ID was added after merge).
101
+
102
+ ## Standalone vs inside a caller
103
+
104
+ - **Standalone** — resolve the target state, extract the branch's issue IDs,
105
+ apply the transition, and report which issues moved and which were skipped. The
106
+ default target is **In Progress** (the start-of-work transition that has no
107
+ other home).
108
+ - **Inside a caller** — the caller supplies the target (and often the `issues`
109
+ list) and decides whether to prompt; the mechanics above are unchanged.
110
+
111
+ ## Implementation
112
+
113
+ No supporting scripts — the skill drives the Linear MCP tools directly
114
+ (`list_issue_statuses`, `get_issue`, `save_issue`). The only repo-specific inputs
115
+ are the team name and the issue-ID prefixes, both read from `config.json`.
@@ -0,0 +1,4 @@
1
+ {
2
+ "linearTeamName": "Your Linear Team",
3
+ "issueKeys": ["ABC", "XYZ"]
4
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "linearTeamName": "ACME Skunkworks",
3
+ "issueKeys": ["ASW", "AKW", "SKW", "SK"]
4
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@acme-skunkworks/skill-linear-sync",
3
+ "version": "0.1.1",
4
+ "private": true,
5
+ "description": "Agent skill: transition the Linear issues linked to the current branch through their workflow states (In Progress / In Review / Done), resolving state IDs by team name and applying transitions idempotently.",
6
+ "keywords": [
7
+ "agent-skill",
8
+ "claude-code",
9
+ "cursor",
10
+ "linear",
11
+ "workflow",
12
+ "issue-tracking"
13
+ ],
14
+ "homepage": "https://github.com/acme-skunkworks/agent-skills/tree/main/skills/linear-sync#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/acme-skunkworks/agent-skills/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/acme-skunkworks/agent-skills.git",
21
+ "directory": "skills/linear-sync"
22
+ },
23
+ "license": "MIT",
24
+ "author": {
25
+ "name": "Rob Easthope",
26
+ "url": "https://github.com/RobEasthope"
27
+ },
28
+ "engines": {
29
+ "node": ">=22"
30
+ }
31
+ }
@@ -0,0 +1,70 @@
1
+ # preflight
2
+
3
+ Change-gated, branch-scoped lint preflight: lint only the categories a branch
4
+ touched (ESLint / markdownlint / actionlint) on `origin/<base>...HEAD` changed
5
+ paths, classify each violation as **introduced** vs **pre-existing**, and drive a
6
+ fix/defer loop via an exit-code contract (0 pass, 1 introduced/blocking, 2
7
+ pre-existing only).
8
+
9
+ ## Install
10
+
11
+ From any consumer repo:
12
+
13
+ ```bash
14
+ npx skills add https://github.com/acme-skunkworks/agent-skills --skill preflight --agent claude-code --agent cursor --copy
15
+ ```
16
+
17
+ `--copy` writes real files so the bundle is portable. Don't use `-g` / `--global`
18
+ — the install should live in the consumer repo.
19
+
20
+ ## Requirements
21
+
22
+ - Node.js ≥22 (per the package's `engines`) for the bundled scripts — **no npm
23
+ dependencies**, Node built-ins only, no build step.
24
+ - The `git` CLI, for the branch/diff analysis.
25
+ - The consumer repo's own **ESLint** and **markdownlint-cli2** (invoked via
26
+ `pnpm exec`), with their configs in place. preflight lints with your toolchain;
27
+ it does not bundle linters.
28
+ - **actionlint** is optional: preflight warns and skips workflow linting if the
29
+ binary isn't on `PATH`.
30
+ - The Linear MCP server is **optional**: the deferred-debt-issue step is skipped
31
+ silently when it is unavailable.
32
+
33
+ ## Configure
34
+
35
+ Both repo-specific inputs **auto-detect**, so most repos configure nothing:
36
+
37
+ - **Linted workspaces** come from `pnpm-workspace.yaml` + each package's `lint`
38
+ script (a workspace without a `lint` script is excluded automatically).
39
+ - **Base branch** comes from `origin/HEAD`, falling back to `main`.
40
+
41
+ To override either, drop a `preflight.config.json` at your **repo root**. A
42
+ [`config.example.json`](config.example.json) ships as a template:
43
+
44
+ ```json
45
+ {
46
+ "baseBranch": "main",
47
+ "workspaces": {
48
+ "web": { "filter": "@acme/web", "prefix": "apps/web/" }
49
+ }
50
+ }
51
+ ```
52
+
53
+ Either key may be supplied on its own; the other is still auto-detected. Use the
54
+ override for non-pnpm repos, deliberate exclusions, or nested workspace globs the
55
+ detector does not expand.
56
+
57
+ ## What it does
58
+
59
+ Run from the repo root:
60
+
61
+ ```bash
62
+ node skills/preflight/scripts/preflight.mjs # the gate
63
+ node skills/preflight/scripts/preflight.mjs --dry-run # report scope only
64
+ node skills/preflight/scripts/lint-fix.mjs # scoped --fix pass
65
+ ```
66
+
67
+ `preflight.mjs` writes `.preflight-summary.json` (categories run, introduced vs
68
+ pre-existing counts) and exits `0` / `1` / `2` per the contract above. See
69
+ [`SKILL.md`](SKILL.md) for the full loop, including how a standalone `/preflight`
70
+ run differs from the lint gate inside a ship flow.
@@ -0,0 +1,148 @@
1
+ ---
2
+ name: preflight
3
+ description: >-
4
+ Run a change-gated, branch-scoped lint preflight (ESLint / markdownlint /
5
+ actionlint) on the files a branch changes versus its base, classify each
6
+ violation as introduced vs pre-existing, and drive the fix/defer loop via an
7
+ exit-code contract (0 pass, 1 introduced/blocking, 2 pre-existing only). Use
8
+ when asked to run preflight, check whether a branch will pass lint before
9
+ pushing, or as the lint gate inside a ship/PR flow. Lints only the categories
10
+ the branch touched — not a whole-repo lint — with linted workspaces and the
11
+ base branch auto-detected, so a consuming repo configures nothing in the
12
+ common case.
13
+ license: MIT
14
+ compatibility: >-
15
+ Requires Node.js ≥22 for the bundled scripts (no npm dependencies — Node
16
+ built-ins only) and the `git` CLI for branch/diff analysis. ESLint,
17
+ markdownlint-cli2, and actionlint are invoked from the consumer repo's own
18
+ toolchain (via `pnpm exec`); actionlint is optional — preflight warns and skips
19
+ workflow linting if its binary is absent. The optional Linear debt-issue step
20
+ needs the Linear MCP server; skip it silently if unavailable.
21
+ metadata:
22
+ version: 0.1.0
23
+ allowed-tools: Read, Bash(git:*), Bash(pnpm:*), Bash(node:*), mcp__linear-server__save_issue, mcp__linear-server__list_issue_statuses
24
+ ---
25
+
26
+ # preflight
27
+
28
+ Change-gated, branch-scoped lint preflight. It lints only the categories relevant
29
+ to `origin/<base>...HEAD`, on changed paths only — not a whole-repo `pnpm lint` —
30
+ then classifies each violation as **introduced** (on a line this branch added or
31
+ changed) or **pre-existing** (already there, in a file the branch happens to
32
+ touch).
33
+
34
+ This skill is the single source of truth for the preflight loop. It is invoked
35
+ two ways:
36
+
37
+ - **Standalone** (`/preflight`) — a quick "will my branch pass scoped lint?"
38
+ check, leaving any fixes in the working tree.
39
+ - **Inside a ship flow** (e.g. `/send-it`) — the lint gate that runs after commits
40
+ and before the changelog/push steps.
41
+
42
+ All bundled scripts use only Node built-ins — no `npm install`, no build step.
43
+ They operate on the **consumer repo's root** (run them from the repo root, where
44
+ `git` resolves the branch diff).
45
+
46
+ ## Running it
47
+
48
+ 1. Make sure the base branch is up to date: `git fetch origin <base>` (the base is
49
+ auto-detected — see Configuration).
50
+ 2. Run the preflight: `node skills/preflight/scripts/preflight.mjs` (append
51
+ `--dry-run` to report categories and scoped file lists without classifying
52
+ violations).
53
+ 3. Read `.preflight-summary.json` for the categories run and the violation counts
54
+ (`passed`, `deferred`, `blocking`).
55
+
56
+ The script's exit code drives the loop:
57
+
58
+ - **Exit 0 — pass.** No introduced violations and every linter ran cleanly.
59
+ Continue.
60
+ - **Exit 1 — introduced violations (blocking).** Run
61
+ `node skills/preflight/scripts/lint-fix.mjs` on the branch-scoped paths, then
62
+ re-run preflight. Repeat until introduced violations clear or the user aborts.
63
+ (Inside a ship flow, commit the fixes; standalone, leave them in the working
64
+ tree for the user to review and commit.)
65
+ - **Exit 2 — pre-existing violations only.** Show the list and ask the user to
66
+ choose:
67
+ - **Fix now** — apply the fixes, (commit if shipping), re-run preflight.
68
+ - **Defer** — open a debt issue in the project's tracker (assign the maintainer;
69
+ link the branch/PR context), then decide whether to continue or abort.
70
+
71
+ Exit 1 can also signal a linter that failed to run (non-zero exit with no
72
+ parseable violations) — inspect its stderr; this is blocking too.
73
+
74
+ ## Categories
75
+
76
+ Each category is gated on what the branch changed (mirrors CI path triggers,
77
+ narrower scope):
78
+
79
+ | Category | Runs when | Skipped when |
80
+ | ------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
81
+ | ESLint | Branch diff includes lintable code or eslint/tsconfig config paths | Markdown-only or non-lintable changes |
82
+ | markdownlint | Branch diff includes `.md` / `.mdx` (respecting repo ignores) | No markdown changes |
83
+ | actionlint | Branch diff includes `.github/workflows/*.yml` or `.github/actionlint.yaml` | No workflow changes; config-only changes lint all tracked workflows; warns and skips if `actionlint` binary missing |
84
+
85
+ ESLint runs per workspace (via `pnpm --filter`), plus a root/scripts bucket.
86
+ Typecheck, tests, and framework checks (e.g. `astro check`) are **not** part of
87
+ preflight — they stay in CI.
88
+
89
+ ## Standalone vs inside a ship flow
90
+
91
+ - **Standalone (`/preflight`)** does the lint preflight and the exit-code loop,
92
+ then **reports**. On introduced violations it may run
93
+ `node skills/preflight/scripts/lint-fix.mjs` and re-run, but it leaves fixes
94
+ **in the working tree** — it never commits, writes a changelog, pushes, or opens
95
+ a PR.
96
+ - **Inside `/send-it`** the same loop runs as the lint gate (after commits, before
97
+ changelog work); fixes are committed so the branch is clean before the changelog
98
+ is written. The changelog and its validation are **separate gates owned by the
99
+ ship flow** — they are not part of this skill.
100
+
101
+ ## Configuration
102
+
103
+ The two repo-specific inputs are auto-detected — a consuming repo edits nothing in
104
+ the common case:
105
+
106
+ - **Linted workspaces** are derived from `pnpm-workspace.yaml` plus each package's
107
+ `package.json`: a workspace is included only if it declares a `lint` script. This
108
+ auto-excludes intentionally-unlinted workspaces and non-package directories
109
+ without a hand-maintained list.
110
+ - **Base branch** is detected from `origin/HEAD` (e.g. `main`, `master`,
111
+ `develop`), falling back to `main` when that symbolic ref is absent.
112
+
113
+ To override either, add a `preflight.config.json` at the **consumer repo root**
114
+ (a [`config.example.json`](config.example.json) ships beside this file as a
115
+ template):
116
+
117
+ ```json
118
+ {
119
+ "baseBranch": "main",
120
+ "workspaces": {
121
+ "web": { "filter": "@acme/web", "prefix": "apps/web/" }
122
+ }
123
+ }
124
+ ```
125
+
126
+ Either key may be supplied on its own; the other is still auto-detected. Use the
127
+ override for non-pnpm repos, deliberate exclusions, or nested workspace globs the
128
+ detector does not expand.
129
+
130
+ ## Implementation
131
+
132
+ The bundled scripts live beside this file under
133
+ [`scripts/`](scripts/) and are invoked directly with `node` — no `pnpm` aliases,
134
+ no `npm install`:
135
+
136
+ - `scripts/preflight.mjs` — the change-gated preflight and exit-code contract.
137
+ - `scripts/lint-fix.mjs` — scoped `eslint --fix` / `markdownlint-cli2 --fix` on the
138
+ branch-changed paths.
139
+ - `scripts/classify-lint.mjs` — parse + classify violations as introduced vs
140
+ pre-existing.
141
+ - `scripts/lib/{scope,diff-lines,paths}.mjs` — shared helpers (workspace/base-branch
142
+ detection, diff-line mapping, path normalisation).
143
+
144
+ They have no external npm dependencies (Node built-ins only).
145
+
146
+ ## Arguments
147
+
148
+ $ARGUMENTS