@verndale/ai-commit 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Verndale
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # @verndale/ai-commit
2
+
3
+ AI-assisted [Conventional Commits](https://www.conventionalcommits.org/) with **bundled [commitlint](https://commitlint.js.org/)** so generated messages match the same rules enforced in hooks.
4
+
5
+ ## Requirements
6
+
7
+ - **Node.js** `>=24.14.0`
8
+ - This repo uses **pnpm** (`packageManager` is pinned in `package.json`; enable via [Corepack](https://nodejs.org/api/corepack.html): `corepack enable`).
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pnpm add -D @verndale/ai-commit
14
+ ```
15
+
16
+ ## Environment
17
+
18
+ - **`OPENAI_API_KEY`** — Required for `ai-commit run` (and for AI-filled `prepare-commit-msg` when you want the model). Optional `COMMIT_AI_MODEL` (default `gpt-4o-mini`).
19
+ - The CLI loads **`.env`** then **`.env.local`** from the current working directory (project root); values in `.env.local` override `.env` for the same key.
20
+ - **Optional tooling** (see [`.env.example`](./.env.example)): `PR_*` for [`tools/open-pr.js`](./tools/open-pr.js) / the **Create or update PR** workflow; `RELEASE_NOTES_AI_*` for the semantic-release notes plugin. Use a GitHub PAT as **`GH_TOKEN`** (or `GITHUB_TOKEN`) when calling the GitHub API outside Actions.
21
+
22
+ ## Commit policy (v2)
23
+
24
+ - **Mandatory scope** — Every header is `type(scope): Subject` (or `type(scope)!:` when breaking). The **scope is not chosen by the model**; it is derived from staged paths (see [`lib/core/message-policy.js`](lib/core/message-policy.js)) and falls back to a short name from `package.json` (e.g. `ai-commit`).
25
+ - **Types** — `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`.
26
+ - **Subject** — Imperative, Beams-style (first word capitalized), max **50** characters, no trailing period.
27
+ - **Body / footer** — Wrap lines at **72** characters when present.
28
+ - **Issues** — If branch or diff mentions `#123`, footers may add `Refs #n` / `Closes #n` (no invented numbers).
29
+ - **Breaking changes** — Only when policy detects governance-related files (commitlint, Husky, this package’s rules/preset); otherwise `!` and `BREAKING CHANGE:` lines are stripped.
30
+ - **Staged diff for AI** — Lockfile and common binary globs are **excluded** from the diff text sent to the model (see [`lib/core/git.js`](lib/core/git.js)); path detection still uses the full staged file list.
31
+
32
+ **Semver:** v2 tightens commitlint (mandatory scope, stricter lengths). If you `extends` this preset, review [lib/rules.js](lib/rules.js) and adjust overrides as needed.
33
+
34
+ ## Commands
35
+
36
+ | Command | Purpose |
37
+ | --- | --- |
38
+ | `ai-commit run` | Generate a message from the staged diff and run `git commit`. |
39
+ | `ai-commit prepare-commit-msg <file> [source]` | Git `prepare-commit-msg` hook: fill an empty message; skips `merge` / `squash`. |
40
+ | `ai-commit lint --edit <file>` | Git `commit-msg` hook: run commitlint with this package’s default config. |
41
+
42
+ ## package.json scripts (example)
43
+
44
+ ```json
45
+ {
46
+ "scripts": {
47
+ "commit": "ai-commit run"
48
+ }
49
+ }
50
+ ```
51
+
52
+ ## Husky (manual setup)
53
+
54
+ Install Husky in your project (`husky` + `"prepare": "husky"` in `package.json` if needed), then add hooks.
55
+
56
+ **`.husky/prepare-commit-msg`**
57
+
58
+ ```sh
59
+ #!/usr/bin/env sh
60
+ . "$(dirname -- "$0")/_/husky.sh"
61
+
62
+ pnpm exec ai-commit prepare-commit-msg "$1" "$2"
63
+ ```
64
+
65
+ **`.husky/commit-msg`**
66
+
67
+ ```sh
68
+ #!/usr/bin/env sh
69
+ . "$(dirname -- "$0")/_/husky.sh"
70
+
71
+ pnpm exec ai-commit lint --edit "$1"
72
+ ```
73
+
74
+ Use `npx` or `yarn` instead if that matches your toolchain.
75
+
76
+ ## commitlint without a second install
77
+
78
+ Use the packaged binary from hooks (`ai-commit lint --edit`) as above.
79
+
80
+ To **extend** the default rules in your own `commitlint.config.js`, you can start from the same preset:
81
+
82
+ ```js
83
+ module.exports = {
84
+ extends: ["@verndale/ai-commit"],
85
+ rules: {
86
+ // optional overrides
87
+ },
88
+ };
89
+ ```
90
+
91
+ Programmatic access to shared constants (types, line limits) is available via:
92
+
93
+ ```js
94
+ const rules = require("@verndale/ai-commit/rules");
95
+ ```
96
+
97
+ ## Development (this repository)
98
+
99
+ ```bash
100
+ corepack enable
101
+ pnpm install
102
+ ```
103
+
104
+ Copy `.env.example` to `.env` and/or `.env.local` and set **`OPENAI_API_KEY`**. After staging, **`pnpm commit`** runs this repo’s CLI (`node ./bin/cli.js run`; the published package exposes `ai-commit` in `node_modules/.bin` for dependents). Hooks under `.husky/` call **`pnpm exec ai-commit`** from this checkout.
105
+
106
+ ### Repository automation
107
+
108
+ | Workflow | Trigger | Purpose |
109
+ | --- | --- | --- |
110
+ | [`.github/workflows/commitlint.yml`](./.github/workflows/commitlint.yml) | PRs to `main`, pushes to non-`main` branches | Commitlint on PR range or last push commit |
111
+ | [`.github/workflows/pr.yml`](./.github/workflows/pr.yml) | Pushes (not `main`) and `workflow_dispatch` | Install deps, run **`pnpm open-pr`** (`node tools/open-pr.js`) — set **`PR_HEAD_BRANCH`** / **`PR_BASE_BRANCH`** in CI via env (workflow sets them). Use a PAT secret **`PR_BOT_TOKEN`** if branch protection requires it; otherwise document your org’s policy. |
112
+ | [`.github/workflows/release.yml`](./.github/workflows/release.yml) | Push to **`main`** (including when a PR merges) | **`semantic-release`** — version bump, `CHANGELOG.md`, git tag, npm publish (with provenance), GitHub Release |
113
+
114
+ Optional **`pnpm open-pr`** locally: set **`GH_TOKEN`** (or **`GITHUB_TOKEN`**) and branch overrides **`PR_BASE_BRANCH`** / **`PR_HEAD_BRANCH`** as needed.
115
+
116
+ ## Publishing (maintainers)
117
+
118
+ Releases are automated with **[semantic-release](https://github.com/semantic-release/semantic-release)** on every push to **`main`** (see [`.releaserc.json`](./.releaserc.json) and [`tools/semantic-release-notes.cjs`](./tools/semantic-release-notes.cjs)).
119
+
120
+ ### Secrets and registry
121
+
122
+ - **`NPM_TOKEN`** (repository or organization secret) — must be able to **`npm publish`** this package in CI **without** an interactive one-time password. The Release workflow sets both `NPM_TOKEN` and `NODE_AUTH_TOKEN` from it.
123
+ - **If the job fails with `EOTP` / “This operation requires a one-time password”:** the account has **2FA** that applies to publishes, and the token is not allowed to bypass OTP in CI. Fix it in one of these ways:
124
+ - **Classic token:** npmjs.com → **Access Tokens** → **Generate New Token** (classic) → type **Automation** (not “Publish”). Store it as **`NPM_TOKEN`**. Automation tokens are for CI and skip OTP on publish.
125
+ - **Granular token:** **New Granular Access Token** → turn on **Bypass two-factor authentication (2FA)**. Under **Packages and scopes**, set permissions to **Read and write** for **`@verndale/ai-commit`** (not “No access”). Leave **Allowed IP ranges** empty unless your org requires it—GitHub Actions egress IPs are not a single fixed range. Copy the token into **`NPM_TOKEN`**.
126
+ - Alternatively, finish **[Trusted Publishing](https://docs.npmjs.com/trusted-publishers)** for this repo and package so OIDC can authorize publishes; you may still need a compatible token or npm-side setup until that path is fully enabled—see npm’s docs for your account type.
127
+ - **`GITHUB_TOKEN`** — provided by Actions for API calls (GitHub Release, etc.). The checkout and git plugin use **`SEMANTIC_RELEASE_TOKEN`** when set; otherwise they use `GITHUB_TOKEN` (see below).
128
+
129
+ **npm provenance:** [`.releaserc.json`](./.releaserc.json) sets `"provenance": true` on `@semantic-release/npm`, which matches **npm Trusted Publishing** from this GitHub repository. On [npmjs.com](https://www.npmjs.com/), enable **Trusted Publishing** for this package linked to **`verndale/ai-commit`** (or your fork’s repo if you test there). If publish fails until that is configured, either finish Trusted Publishing setup or temporarily set `"provenance": false` in `.releaserc.json` (you lose the provenance badge).
130
+
131
+ ### Branch protection and release commits
132
+
133
+ semantic-release pushes a **release commit** and **tag** back to `main` via `@semantic-release/git`. If **`main`** is protected and the default token cannot push, either allow **GitHub Actions** to bypass protection for this repository, or add a personal access token (classic: `repo`, or fine-grained: **Contents** read/write on this repo) as **`SEMANTIC_RELEASE_TOKEN`**. The Release workflow passes `SEMANTIC_RELEASE_TOKEN || GITHUB_TOKEN` to checkout and to semantic-release as `GITHUB_TOKEN`.
134
+
135
+ ### Commits that produce releases
136
+
137
+ **Conventional Commits** on `main` drive `@semantic-release/commit-analyzer` (patch / minor / major). The analyzer uses the **first line** of each commit since the last tag; long PR bodies do not substitute for a releasable header.
138
+
139
+ With the default plugin configuration in [`.releaserc.json`](./.releaserc.json) (no custom `releaseRules`), commits whose type is only **`chore`**, **`docs`**, **`ci`**, **`style`**, **`test`**, **`build`**, etc. **do not** trigger a version bump, `CHANGELOG.md` update, or tag. To ship semver for user-facing work, use a squash **PR title** (or merge commit message) with a releasable type—typically **`feat`**, **`fix`**, **`perf`**, or **`revert`**, or a **breaking** change (`!` / `BREAKING CHANGE:`). For **squash merge**, the merged commit message is usually the **PR title**, so match commitlint there. PR checks lint the PR title and the commits on the branch.
140
+
141
+ If the project ever needs patch releases from `chore`/`docs`-only merges, maintainers can add **`releaseRules`** to `@semantic-release/commit-analyzer` in `.releaserc.json`; the default is to skip those types so releases stay signal-heavy.
142
+
143
+ Tag-only npm publish was removed in favor of this flow to avoid double publishes. To try a release locally: `pnpm release` (requires appropriate tokens and git state; use a fork or `--dry-run` as appropriate).
144
+
145
+ ## License
146
+
147
+ MIT — see [LICENSE](./LICENSE).
package/bin/cli.js ADDED
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const path = require("path");
5
+ const fs = require("fs");
6
+ const { spawnSync } = require("child_process");
7
+
8
+ require("../lib/load-project-env.js").loadProjectEnv();
9
+
10
+ const { generateAndValidate } = require("../lib/core/generate.js");
11
+ const {
12
+ assertInGitRepo,
13
+ hasStagedChanges,
14
+ commitFromFile,
15
+ } = require("../lib/core/git.js");
16
+
17
+ function presetPath() {
18
+ return path.join(__dirname, "..", "lib", "commitlint-preset.cjs");
19
+ }
20
+
21
+ function commitlintCliPath() {
22
+ return require.resolve("@commitlint/cli/cli.js");
23
+ }
24
+
25
+ function printHelp() {
26
+ process.stdout.write(`ai-commit — conventional commits + bundled commitlint (mandatory deterministic scope; see README).
27
+
28
+ Usage:
29
+ ai-commit run
30
+ ai-commit prepare-commit-msg <file> [source]
31
+ ai-commit lint --edit <file>
32
+
33
+ Commands:
34
+ run Generate a message from the staged diff and run git commit.
35
+ prepare-commit-msg Git hook: fill an empty commit message file (merge/squash skipped).
36
+ lint Run commitlint with the package default config (for commit-msg hook).
37
+
38
+ Environment:
39
+ OPENAI_API_KEY Required for AI generation on \`run\` (and for prepare-commit-msg when you want AI).
40
+ COMMIT_AI_MODEL Optional OpenAI model (default: gpt-4o-mini).
41
+
42
+ Loads \`.env\` then \`.env.local\` from the current working directory (\`.env.local\` overrides).
43
+ `);
44
+ }
45
+
46
+ function parseLintArgv(argv) {
47
+ const i = argv.indexOf("--edit");
48
+ if (i === -1 || !argv[i + 1]) {
49
+ throw new Error("Missing --edit <file> (example: ai-commit lint --edit \"$1\")");
50
+ }
51
+ return { file: argv[i + 1] };
52
+ }
53
+
54
+ function stripGitComments(text) {
55
+ return text
56
+ .split("\n")
57
+ .filter((line) => !/^\s*#/.test(line))
58
+ .join("\n");
59
+ }
60
+
61
+ async function cmdRun() {
62
+ assertInGitRepo();
63
+ if (!hasStagedChanges()) {
64
+ process.stderr.write("No staged changes. Stage files before running ai-commit (e.g. pnpm commit).\n");
65
+ process.exit(1);
66
+ }
67
+ const { message, warnings } = await generateAndValidate(process.cwd(), {
68
+ requireOpenAI: true,
69
+ });
70
+ for (const w of warnings) {
71
+ process.stderr.write(`warning: ${w}\n`);
72
+ }
73
+ commitFromFile(message);
74
+ }
75
+
76
+ async function cmdPrepareCommitMsg(file, source) {
77
+ if (source === "merge" || source === "squash") {
78
+ process.exit(0);
79
+ }
80
+ assertInGitRepo();
81
+ const raw = fs.readFileSync(file, "utf8");
82
+ const cleaned = stripGitComments(raw).trim();
83
+ if (cleaned.length > 0) {
84
+ process.exit(0);
85
+ }
86
+ if (!hasStagedChanges()) {
87
+ process.exit(0);
88
+ }
89
+ const { message, warnings } = await generateAndValidate(process.cwd(), {
90
+ requireOpenAI: false,
91
+ });
92
+ for (const w of warnings) {
93
+ process.stderr.write(`warning: ${w}\n`);
94
+ }
95
+ fs.writeFileSync(file, message, "utf8");
96
+ }
97
+
98
+ function cmdLint(editFile) {
99
+ const abs = path.isAbsolute(editFile)
100
+ ? editFile
101
+ : path.join(process.cwd(), editFile);
102
+ const r = spawnSync(
103
+ process.execPath,
104
+ [
105
+ commitlintCliPath(),
106
+ "--edit",
107
+ abs,
108
+ "--config",
109
+ presetPath(),
110
+ ],
111
+ { stdio: "inherit", cwd: process.cwd() },
112
+ );
113
+ process.exit(r.status ?? 1);
114
+ }
115
+
116
+ async function main() {
117
+ const argv = process.argv.slice(2);
118
+ const cmd = argv[0];
119
+ if (!cmd || cmd === "-h" || cmd === "--help") {
120
+ printHelp();
121
+ process.exit(cmd ? 0 : 1);
122
+ }
123
+ if (cmd === "run") {
124
+ await cmdRun();
125
+ return;
126
+ }
127
+ if (cmd === "prepare-commit-msg") {
128
+ const file = argv[1];
129
+ const source = argv[2];
130
+ if (!file) {
131
+ throw new Error("Usage: ai-commit prepare-commit-msg <file> [source]");
132
+ }
133
+ await cmdPrepareCommitMsg(file, source);
134
+ return;
135
+ }
136
+ if (cmd === "lint") {
137
+ const { file } = parseLintArgv(argv);
138
+ cmdLint(file);
139
+ return;
140
+ }
141
+ throw new Error(`Unknown command: ${cmd}`);
142
+ }
143
+
144
+ main().catch((e) => {
145
+ process.stderr.write(`${e.message}\n`);
146
+ process.exit(1);
147
+ });
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+
3
+ const { getCommitlintRuleOverrides } = require("./rules.js");
4
+
5
+ module.exports = {
6
+ extends: ["@commitlint/config-conventional"],
7
+ rules: getCommitlintRuleOverrides(),
8
+ };
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+
3
+ const { lintMessage } = require("./lint.js");
4
+ const { generateCommitMessageFull } = require("./openai.js");
5
+ const {
6
+ detectIssueNumbers,
7
+ detectScopeFromFiles,
8
+ looksBreaking,
9
+ buildFallbackSubject,
10
+ } = require("./message-policy.js");
11
+ const {
12
+ getStagedDiff,
13
+ getChangedFiles,
14
+ getBranchName,
15
+ } = require("./git.js");
16
+
17
+ function buildChoreFallback({ files, scope, issueNumbers }) {
18
+ const subject = buildFallbackSubject(files);
19
+ let msg = `chore(${scope}): ${subject}\n\nSummarize staged changes and keep repository governance consistent.`;
20
+ if (issueNumbers.length) msg += `\n\nRefs #${issueNumbers[0]}`;
21
+ return msg;
22
+ }
23
+
24
+ async function generateAndValidate(
25
+ cwd = process.cwd(),
26
+ { requireOpenAI = false } = {},
27
+ ) {
28
+ const diff = getStagedDiff(cwd);
29
+ const files = getChangedFiles(cwd);
30
+ const branchName = getBranchName(cwd);
31
+ const issueNumbers = detectIssueNumbers({ branchName, diffText: diff });
32
+ const scope = detectScopeFromFiles(files, cwd);
33
+ const breakingAllowed = looksBreaking({ files });
34
+
35
+ if (requireOpenAI && !process.env.OPENAI_API_KEY) {
36
+ const err = new Error(
37
+ "OPENAI_API_KEY is not set. Add it to your environment or a .env / .env.local file in the project root.",
38
+ );
39
+ err.code = "ENOKEY";
40
+ throw err;
41
+ }
42
+
43
+ let msg = "";
44
+ let usedAi = false;
45
+ if (process.env.OPENAI_API_KEY) {
46
+ try {
47
+ msg = await generateCommitMessageFull(
48
+ {
49
+ diff,
50
+ files,
51
+ issueNumbers,
52
+ scope,
53
+ breakingAllowed,
54
+ },
55
+ { cwd },
56
+ );
57
+ if (msg) usedAi = true;
58
+ } catch (e) {
59
+ if (e.code === "ENOKEY") throw e;
60
+ msg = "";
61
+ }
62
+ }
63
+
64
+ if (!msg) {
65
+ msg = buildChoreFallback({ files, scope, issueNumbers });
66
+ }
67
+
68
+ let result = await lintMessage(msg, cwd);
69
+ if (result.valid) {
70
+ const warnings = [];
71
+ if (!usedAi) {
72
+ if (!process.env.OPENAI_API_KEY) {
73
+ warnings.push("OPENAI_API_KEY not set; used deterministic fallback message.");
74
+ } else {
75
+ warnings.push("Model output could not be used; used deterministic fallback message.");
76
+ }
77
+ }
78
+ return { message: msg, warnings };
79
+ }
80
+
81
+ const fallback = buildChoreFallback({ files, scope, issueNumbers });
82
+ result = await lintMessage(fallback, cwd);
83
+ if (result.valid) {
84
+ return {
85
+ message: fallback,
86
+ warnings: ["Used chore fallback after generated message failed commitlint."],
87
+ };
88
+ }
89
+
90
+ const minimal = `chore(${scope}): Update staged changes`;
91
+ result = await lintMessage(minimal, cwd);
92
+ if (result.valid) {
93
+ return {
94
+ message: minimal,
95
+ warnings: ["Used generic fallback after validation failed."],
96
+ };
97
+ }
98
+
99
+ const errors = [...result.errors, ...result.warnings]
100
+ .map((x) => x.message)
101
+ .filter(Boolean);
102
+ throw new Error(
103
+ `Commit message failed validation:\n${errors.join("\n") || result.input}`,
104
+ );
105
+ }
106
+
107
+ module.exports = { generateAndValidate, buildChoreFallback };
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+
3
+ const { execFileSync } = require("child_process");
4
+
5
+ /** Large staged diffs must not throw ENOBUFS (align with generous buffer in prior tooling). */
6
+ const GIT_DIFF_MAX_BUFFER = 50 * 1024 * 1024;
7
+
8
+ /** Pathspecs excluded from staged diff text (noise / binary). */
9
+ const DIFF_EXCLUDE_PATHSPECS = [
10
+ ":!pnpm-lock.yaml",
11
+ ":!*.png",
12
+ ":!*.jpg",
13
+ ":!*.jpeg",
14
+ ":!*.pdf",
15
+ ];
16
+
17
+ function execGit(args, options = {}) {
18
+ return execFileSync("git", args, {
19
+ encoding: "utf8",
20
+ maxBuffer: GIT_DIFF_MAX_BUFFER,
21
+ ...options,
22
+ });
23
+ }
24
+
25
+ function assertInGitRepo(cwd = process.cwd()) {
26
+ try {
27
+ execGit(["rev-parse", "--git-dir"], { cwd });
28
+ } catch {
29
+ const err = new Error("Not a git repository (or git not available).");
30
+ err.code = "ENOTGIT";
31
+ throw err;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Staged diff for AI prompts; excludes lockfile and common binary globs.
37
+ */
38
+ function getStagedDiff(cwd = process.cwd()) {
39
+ assertInGitRepo(cwd);
40
+ return execGit(
41
+ ["diff", "--cached", "--no-color", "--no-ext-diff", "--", ...DIFF_EXCLUDE_PATHSPECS],
42
+ { cwd },
43
+ );
44
+ }
45
+
46
+ function getChangedFiles(cwd = process.cwd()) {
47
+ assertInGitRepo(cwd);
48
+ return execGit(["diff", "--cached", "--name-only"], { cwd })
49
+ .trim()
50
+ .split("\n")
51
+ .filter(Boolean);
52
+ }
53
+
54
+ function getBranchName(cwd = process.cwd()) {
55
+ assertInGitRepo(cwd);
56
+ try {
57
+ return execGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd }).trim();
58
+ } catch {
59
+ return "";
60
+ }
61
+ }
62
+
63
+ function getStagedStatSummary(cwd = process.cwd()) {
64
+ assertInGitRepo(cwd);
65
+ try {
66
+ return execGit(["diff", "--cached", "--stat"], { cwd }).trim();
67
+ } catch {
68
+ return "";
69
+ }
70
+ }
71
+
72
+ function hasStagedChanges(cwd = process.cwd()) {
73
+ assertInGitRepo(cwd);
74
+ const out = execGit(["diff", "--cached", "--name-only"], { cwd });
75
+ return out.trim().length > 0;
76
+ }
77
+
78
+ function commitFromFile(message, cwd = process.cwd()) {
79
+ execFileSync("git", ["commit", "-F", "-"], {
80
+ cwd,
81
+ input: message,
82
+ encoding: "utf8",
83
+ maxBuffer: GIT_DIFF_MAX_BUFFER,
84
+ stdio: ["pipe", "inherit", "inherit"],
85
+ });
86
+ }
87
+
88
+ module.exports = {
89
+ GIT_DIFF_MAX_BUFFER,
90
+ DIFF_EXCLUDE_PATHSPECS,
91
+ execGit,
92
+ assertInGitRepo,
93
+ getStagedDiff,
94
+ getChangedFiles,
95
+ getBranchName,
96
+ getStagedStatSummary,
97
+ hasStagedChanges,
98
+ commitFromFile,
99
+ };
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+
5
+ /**
6
+ * Lint full commit message text with the packaged commitlint preset (same rules as `ai-commit lint`).
7
+ */
8
+ async function lintMessage(message, cwd = process.cwd()) {
9
+ const presetPath = path.join(__dirname, "..", "commitlint-preset.cjs");
10
+ const [{ default: load }, { default: lint }] = await Promise.all([
11
+ import("@commitlint/load"),
12
+ import("@commitlint/lint"),
13
+ ]);
14
+ const config = await load({}, { file: presetPath, cwd });
15
+ const result = await lint(message, config.rules, {
16
+ parserOpts: config.parserPreset?.parserOpts,
17
+ defaultIgnores: config.defaultIgnores,
18
+ ignores: config.ignores,
19
+ plugins: config.plugins ?? {},
20
+ });
21
+ return result;
22
+ }
23
+
24
+ module.exports = { lintMessage };
@@ -0,0 +1,189 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const {
6
+ COMMIT_TYPES,
7
+ SUBJECT_MAX_LENGTH,
8
+ } = require("../rules.js");
9
+
10
+ function unique(arr) {
11
+ return Array.from(new Set(arr));
12
+ }
13
+
14
+ /**
15
+ * Issue numbers from branch name and diff (#123).
16
+ */
17
+ function detectIssueNumbers({ branchName, diffText }) {
18
+ const nums = [];
19
+ const branchMatches = branchName.match(/#\d+|\b\d{1,6}\b/g);
20
+ if (branchMatches) {
21
+ for (const m of branchMatches) {
22
+ const n = String(m).replace("#", "");
23
+ if (/^\d{1,6}$/.test(n)) nums.push(n);
24
+ }
25
+ }
26
+ const diffMatches = diffText.match(/#(\d{1,6})/g);
27
+ if (diffMatches) {
28
+ for (const m of diffMatches) {
29
+ const n = m.replace("#", "");
30
+ if (/^\d{1,6}$/.test(n)) nums.push(n);
31
+ }
32
+ }
33
+ return unique(nums);
34
+ }
35
+
36
+ /**
37
+ * Deterministic scope for @verndale/ai-commit repository layout.
38
+ */
39
+ function detectScopeFromFiles(files, cwd = process.cwd()) {
40
+ const f = (p) => files.some((x) => x.startsWith(p));
41
+ if (f("lib/")) return "lib";
42
+ if (f("bin/")) return "cli";
43
+ if (f(".github/")) return "ci";
44
+ if (f("docs/")) return "docs";
45
+ return getDefaultScopeFromPackage(cwd);
46
+ }
47
+
48
+ function getDefaultScopeFromPackage(cwd) {
49
+ const pkgPath = path.join(cwd, "package.json");
50
+ try {
51
+ if (fs.existsSync(pkgPath)) {
52
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
53
+ if (pkg.name && typeof pkg.name === "string") {
54
+ const n = pkg.name;
55
+ if (n.includes("/")) return n.split("/").pop().replace(/^@/, "") || "repo";
56
+ return n.replace(/^@/, "") || "repo";
57
+ }
58
+ }
59
+ } catch {
60
+ /* ignore */
61
+ }
62
+ return "repo";
63
+ }
64
+
65
+ /**
66
+ * Breaking allowed when governance / hook / preset files change.
67
+ */
68
+ function looksBreaking({ files }) {
69
+ return files.some(
70
+ (file) =>
71
+ file.includes("commitlint.config") ||
72
+ file.endsWith("commitlint-preset.cjs") ||
73
+ file.endsWith("lib/rules.js") ||
74
+ file.startsWith(".husky/"),
75
+ );
76
+ }
77
+
78
+ function wrap72(text) {
79
+ const width = 72;
80
+ const lines = [];
81
+ for (const paragraph of text.split(/\n\n+/)) {
82
+ const p = paragraph.trim();
83
+ if (!p) {
84
+ lines.push("");
85
+ continue;
86
+ }
87
+ const words = p.split(/\s+/);
88
+ let line = "";
89
+ for (const w of words) {
90
+ if (!line) line = w;
91
+ else if (line.length + 1 + w.length <= width) line += ` ${w}`;
92
+ else {
93
+ lines.push(line);
94
+ line = w;
95
+ }
96
+ }
97
+ if (line) lines.push(line);
98
+ lines.push("");
99
+ }
100
+ while (lines.length && lines[lines.length - 1] === "") lines.pop();
101
+ return lines.join("\n");
102
+ }
103
+
104
+ function stripGitComments(text) {
105
+ return text
106
+ .split("\n")
107
+ .filter((line) => !line.trim().startsWith("#"))
108
+ .join("\n")
109
+ .trim();
110
+ }
111
+
112
+ function parseMessage(raw) {
113
+ const cleaned = stripGitComments(raw || "");
114
+ const parts = cleaned.split(/\n\n+/);
115
+ const header = (parts[0] || "").trim();
116
+ const rest = parts.slice(1).join("\n\n").trim();
117
+ return { header, rest };
118
+ }
119
+
120
+ function stripBreakingFooterLines(text) {
121
+ const lines = text.split("\n").filter((line) => !/^BREAKING CHANGE:/i.test(line.trim()));
122
+ while (lines.length && lines[lines.length - 1].trim() === "") lines.pop();
123
+ return lines.join("\n").trim();
124
+ }
125
+
126
+ function extractTypeAndSubject(header) {
127
+ const types = COMMIT_TYPES.join("|");
128
+ const re = new RegExp(
129
+ `^(${types})(?:\\([^)]+\\))?(!)?:\\s(.+)$`,
130
+ );
131
+ const m = header.match(re);
132
+ if (!m) {
133
+ return {
134
+ type: "chore",
135
+ subject: header.replace(/^[^:]+:\s*/, "").trim(),
136
+ aiBreaking: false,
137
+ };
138
+ }
139
+ return { type: m[1], subject: (m[3] || "").trim(), aiBreaking: !!m[2] };
140
+ }
141
+
142
+ function normalizeSubject(subject) {
143
+ let s = (subject || "").trim();
144
+ while (s.endsWith(".")) s = s.slice(0, -1);
145
+ if (s && !/^[A-Z]/.test(s)) s = s.charAt(0).toUpperCase() + s.slice(1);
146
+ if (s.length > SUBJECT_MAX_LENGTH) s = s.slice(0, SUBJECT_MAX_LENGTH).trimEnd();
147
+ return s;
148
+ }
149
+
150
+ function buildFallbackSubject(files) {
151
+ const buckets = [];
152
+ if (files.some((f) => f.includes("release") || f.includes(".releaserc") || f.includes("CHANGELOG"))) {
153
+ buckets.push("Release automation");
154
+ }
155
+ if (
156
+ files.some(
157
+ (f) =>
158
+ f.includes("commitlint") ||
159
+ f.includes(".husky") ||
160
+ f.includes("CONTRIBUTING"),
161
+ )
162
+ ) {
163
+ buckets.push("Commit governance");
164
+ }
165
+ if (files.some((f) => f.startsWith(".github/"))) buckets.push("CI workflow");
166
+ if (files.some((f) => f.startsWith("lib/") || f.startsWith("bin/"))) {
167
+ buckets.push("Package implementation");
168
+ }
169
+ if (files.some((f) => f.startsWith("docs/"))) buckets.push("Documentation");
170
+
171
+ const summary = buckets.length ? buckets[0] : "Repository updates";
172
+ let s = summary.length > SUBJECT_MAX_LENGTH ? summary.slice(0, SUBJECT_MAX_LENGTH) : summary;
173
+ if (s && !/^[A-Z]/.test(s)) s = s.charAt(0).toUpperCase() + s.slice(1);
174
+ return s;
175
+ }
176
+
177
+ module.exports = {
178
+ detectIssueNumbers,
179
+ detectScopeFromFiles,
180
+ getDefaultScopeFromPackage,
181
+ looksBreaking,
182
+ wrap72,
183
+ stripGitComments,
184
+ parseMessage,
185
+ stripBreakingFooterLines,
186
+ extractTypeAndSubject,
187
+ normalizeSubject,
188
+ buildFallbackSubject,
189
+ };
@@ -0,0 +1,249 @@
1
+ "use strict";
2
+
3
+ const OpenAI = require("openai");
4
+ const { COMMIT_TYPES } = require("../rules.js");
5
+ const {
6
+ parseMessage,
7
+ extractTypeAndSubject,
8
+ normalizeSubject,
9
+ stripBreakingFooterLines,
10
+ wrap72,
11
+ } = require("./message-policy.js");
12
+
13
+ const DEFAULT_MODEL = "gpt-4o-mini";
14
+ const DIFF_PROMPT_SLICE = 12000;
15
+
16
+ function getClient() {
17
+ const key = process.env.OPENAI_API_KEY;
18
+ if (!key) {
19
+ const err = new Error(
20
+ "OPENAI_API_KEY is not set. Add it to your environment or a .env / .env.local file in the project root.",
21
+ );
22
+ err.code = "ENOKEY";
23
+ throw err;
24
+ }
25
+ return new OpenAI({ apiKey: key });
26
+ }
27
+
28
+ async function callOpenAI({ prompt, model = process.env.COMMIT_AI_MODEL || DEFAULT_MODEL }) {
29
+ const openai = getClient();
30
+ const response = await openai.chat.completions.create({
31
+ model,
32
+ temperature: 0.1,
33
+ messages: [
34
+ {
35
+ role: "system",
36
+ content:
37
+ "You produce strict Conventional Commit messages. Body text follows classic Beams-style prose: full sentences, imperative clarity, 72-character wrap, and normal sentence capitalization (capitalize the first word of each sentence; proper nouns as in English).",
38
+ },
39
+ { role: "user", content: prompt },
40
+ ],
41
+ });
42
+ return response.choices?.[0]?.message?.content?.trim() || "";
43
+ }
44
+
45
+ function coerceType(type) {
46
+ return COMMIT_TYPES.includes(type) ? type : "chore";
47
+ }
48
+
49
+ function assembleFromRaw(raw, { scope, breakingAllowed, issueNumbers }) {
50
+ if (!raw) return "";
51
+ let parsed = parseMessage(raw);
52
+ let { type, subject, aiBreaking } = extractTypeAndSubject(parsed.header);
53
+ type = coerceType(type);
54
+
55
+ const aiFooterBreaking = /^BREAKING CHANGE:/im.test(parsed.rest || "");
56
+ const finalBreaking = !!(breakingAllowed && (aiBreaking || aiFooterBreaking));
57
+
58
+ subject = normalizeSubject(subject);
59
+ if (!subject) return "";
60
+
61
+ parsed.header = `${type}(${scope})${finalBreaking ? "!" : ""}: ${subject}`;
62
+
63
+ if (!finalBreaking) parsed.rest = stripBreakingFooterLines(parsed.rest || "");
64
+
65
+ const wrappedRest = parsed.rest ? wrap72(parsed.rest.trim()) : "";
66
+ let msg = parsed.header + (wrappedRest ? `\n\n${wrappedRest}` : "");
67
+
68
+ if (issueNumbers.length) {
69
+ const lower = msg.toLowerCase();
70
+ const missingAll = issueNumbers.every((n) => !lower.includes(`#${n}`));
71
+ if (missingAll) msg += `\n\n${issueNumbers.map((n) => `Refs #${n}`).join("\n")}`;
72
+ }
73
+
74
+ return msg.trim();
75
+ }
76
+
77
+ function formatLintErrors(result) {
78
+ return [...result.errors, ...result.warnings]
79
+ .map((x) => `${x.name}: ${x.message}`)
80
+ .filter(Boolean)
81
+ .join("\n");
82
+ }
83
+
84
+ function buildBasePrompt({
85
+ diff,
86
+ files,
87
+ issueNumbers,
88
+ breakingAllowed,
89
+ }) {
90
+ const issueHint = issueNumbers.length
91
+ ? `
92
+ ISSUE REFERENCES:
93
+ Detected issue numbers:
94
+ ${issueNumbers.map((n) => `- #${n}`).join("\n")}
95
+
96
+ Footer:
97
+ - Use "Closes #<n>" only if the commit fully resolves the issue
98
+ - Otherwise use "Refs #<n>"
99
+ - One reference per line
100
+ - Do NOT invent issue numbers
101
+ `
102
+ : `
103
+ ISSUE REFERENCES:
104
+ No issue numbers detected.
105
+ Do NOT invent issue references.
106
+ `;
107
+
108
+ const breakingRules = breakingAllowed
109
+ ? `
110
+ BREAKING CHANGE:
111
+ You MAY mark this as breaking if it truly breaks compatibility.
112
+ If breaking, include:
113
+ - "!" after the type (before the colon), e.g. feat!: Subject
114
+ - Footer line: BREAKING CHANGE: <short explanation>
115
+ If NOT breaking, do not include "!" or BREAKING CHANGE footer.
116
+ `
117
+ : `
118
+ BREAKING CHANGE:
119
+ You are NOT allowed to mark this as breaking.
120
+ Do NOT add "!" before the colon.
121
+ Do NOT add any "BREAKING CHANGE:" footer lines.
122
+ `;
123
+
124
+ const typesList = COMMIT_TYPES.join(", ");
125
+
126
+ return `
127
+ Generate ONE git commit message.
128
+
129
+ You may choose the <type> from: ${typesList}.
130
+ Do NOT choose a scope; scope will be injected automatically.
131
+
132
+ Style: classic "How to Write a Git Commit Message" (Beams-style) prose—clear, imperative subject line; body is readable narrative, not bullet fragments or all-lowercase streams.
133
+
134
+ Format (first line MUST be without scope — use type only):
135
+ <type>: <Subject>
136
+ (blank line)
137
+ <Detailed Body>
138
+ (blank line)
139
+ <Footer>
140
+
141
+ Subject rules:
142
+ - Imperative mood
143
+ - First letter capitalized
144
+ - Max 50 characters
145
+ - No trailing period
146
+
147
+ Body rules:
148
+ - Robust body (2–4 short paragraphs) when the change warrants explanation
149
+ - Wrap at 72 characters max
150
+ - Explain WHAT changed, WHY, and IMPACT
151
+ - Write in complete sentences with normal sentence capitalization.
152
+
153
+ Footer rules:
154
+ - Issue references only if detected
155
+ - Breaking footer only if allowed
156
+
157
+ Formatting rules:
158
+ - Separate header/body/footer with ONE blank line each
159
+ - No markdown fences, no backticks
160
+ - Output ONLY the commit message text
161
+
162
+ ${issueHint}
163
+ ${breakingRules}
164
+
165
+ Changed files:
166
+ ${files.join("\n")}
167
+
168
+ Staged diff (truncated):
169
+ ${diff.slice(0, DIFF_PROMPT_SLICE)}
170
+ `.trim();
171
+ }
172
+
173
+ function buildRetryPrompt(basePrompt, lintFeedback) {
174
+ return `
175
+ Your previous output failed commitlint:
176
+ ${lintFeedback}
177
+
178
+ Regenerate the commit message. Remember:
179
+ - First line format: <type>: <Subject> only (no scope in the first line)
180
+ - Subject <= 50 chars, capitalized, no trailing period
181
+ - Types allowed: ${COMMIT_TYPES.join(", ")}
182
+ - Provide a robust body in Beams-style prose when appropriate
183
+ Return ONLY the commit message text.
184
+
185
+ ${basePrompt}
186
+ `.trim();
187
+ }
188
+
189
+ /**
190
+ * Full pipeline: AI (no scope in header) → inject deterministic scope → wrap → issue footers → lint → one retry.
191
+ */
192
+ async function generateCommitMessageFull(
193
+ {
194
+ diff,
195
+ files,
196
+ issueNumbers,
197
+ scope,
198
+ breakingAllowed,
199
+ },
200
+ { cwd, model } = {},
201
+ ) {
202
+ const basePrompt = buildBasePrompt({
203
+ diff,
204
+ files,
205
+ issueNumbers,
206
+ breakingAllowed,
207
+ });
208
+
209
+ const { lintMessage } = require("./lint.js");
210
+
211
+ let raw = "";
212
+ try {
213
+ raw = await callOpenAI({ prompt: basePrompt, model });
214
+ } catch {
215
+ return "";
216
+ }
217
+
218
+ let msg = assembleFromRaw(raw, { scope, breakingAllowed, issueNumbers });
219
+ if (!msg) return "";
220
+
221
+ let result = await lintMessage(msg, cwd);
222
+ if (result.valid) return msg;
223
+
224
+ const feedback = formatLintErrors(result);
225
+ let retryRaw = "";
226
+ try {
227
+ retryRaw = await callOpenAI({
228
+ prompt: buildRetryPrompt(basePrompt, feedback),
229
+ model,
230
+ });
231
+ } catch {
232
+ return "";
233
+ }
234
+
235
+ msg = assembleFromRaw(retryRaw, { scope, breakingAllowed, issueNumbers });
236
+ if (!msg) return "";
237
+
238
+ result = await lintMessage(msg, cwd);
239
+ return result.valid ? msg : "";
240
+ }
241
+
242
+ module.exports = {
243
+ generateCommitMessageFull,
244
+ callOpenAI,
245
+ assembleFromRaw,
246
+ buildBasePrompt,
247
+ getClient,
248
+ DEFAULT_MODEL,
249
+ };
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+ const dotenv = require("dotenv");
5
+
6
+ /**
7
+ * Load `.env` then `.env.local` from cwd (`.env.local` overrides duplicate keys).
8
+ * @param {string} [cwd=process.cwd()]
9
+ */
10
+ function loadProjectEnv(cwd = process.cwd()) {
11
+ dotenv.config({ path: path.join(cwd, ".env") });
12
+ dotenv.config({ path: path.join(cwd, ".env.local"), override: true });
13
+ }
14
+
15
+ module.exports = { loadProjectEnv };
package/lib/rules.js ADDED
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+
3
+ /** @see @commitlint/types RuleConfigSeverity — avoid require() (package is ESM-only). */
4
+ const ERROR = 2;
5
+ const OFF = 0;
6
+
7
+ /**
8
+ * Single source of truth for commit types, lengths, and commitlint rules.
9
+ * Scope is mandatory (injected deterministically by the generator); Beams-style subjects
10
+ * start with a capital letter, so subject-case from conventional config is disabled.
11
+ */
12
+
13
+ const COMMIT_TYPES = [
14
+ "build",
15
+ "chore",
16
+ "ci",
17
+ "docs",
18
+ "feat",
19
+ "fix",
20
+ "perf",
21
+ "refactor",
22
+ "revert",
23
+ "style",
24
+ "test",
25
+ ];
26
+
27
+ /** Subject line only (after `type(scope):`). */
28
+ const SUBJECT_MAX_LENGTH = 50;
29
+
30
+ /** Full first line: type(scope)!: subject */
31
+ const HEADER_MAX_LENGTH = 120;
32
+
33
+ const BODY_MAX_LINE_LENGTH = 72;
34
+ const FOOTER_MAX_LINE_LENGTH = 72;
35
+
36
+ /** Scopes produced by detectScopeFromFiles in this package (lowercase, hyphens). */
37
+ const SCOPE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
38
+
39
+ function getCommitlintRuleOverrides() {
40
+ return {
41
+ "type-enum": [ERROR, "always", COMMIT_TYPES],
42
+ "scope-empty": [ERROR, "never"],
43
+ "scope-case": [ERROR, "always", "lower-case"],
44
+ "subject-max-length": [ERROR, "always", SUBJECT_MAX_LENGTH],
45
+ "subject-case": [OFF],
46
+ "header-max-length": [ERROR, "always", HEADER_MAX_LENGTH],
47
+ "body-max-line-length": [ERROR, "always", BODY_MAX_LINE_LENGTH],
48
+ "footer-max-line-length": [ERROR, "always", FOOTER_MAX_LINE_LENGTH],
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Short policy text for docs / external tooling (generator uses richer prompts in openai.js).
54
+ */
55
+ function getPromptInstructions() {
56
+ return [
57
+ "Conventional Commits with mandatory scope: type(scope): Subject (or type(scope)!: when breaking).",
58
+ "",
59
+ `Types: ${COMMIT_TYPES.join(", ")}.`,
60
+ "Scope is chosen deterministically from changed paths (not by the model).",
61
+ `Subject: imperative, first word capitalized (Beams-style), max ${SUBJECT_MAX_LENGTH} characters, no trailing period.`,
62
+ `Body: optional; if present, blank line after header; wrap at ${BODY_MAX_LINE_LENGTH} characters.`,
63
+ `Footer: issues (Refs #n / Closes #n), BREAKING CHANGE: when applicable; wrap at ${FOOTER_MAX_LINE_LENGTH} characters.`,
64
+ ].join("\n");
65
+ }
66
+
67
+ module.exports = {
68
+ COMMIT_TYPES,
69
+ SUBJECT_MAX_LENGTH,
70
+ HEADER_MAX_LENGTH,
71
+ BODY_MAX_LINE_LENGTH,
72
+ FOOTER_MAX_LINE_LENGTH,
73
+ SCOPE_PATTERN,
74
+ getCommitlintRuleOverrides,
75
+ getPromptInstructions,
76
+ };
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@verndale/ai-commit",
3
+ "version": "2.0.0",
4
+ "description": "AI-assisted conventional commits with bundled commitlint — one install, aligned rules",
5
+ "license": "MIT",
6
+ "author": "Verndale",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/verndale/ai-commit.git"
10
+ },
11
+ "keywords": [
12
+ "commit",
13
+ "commitlint",
14
+ "conventional-commits",
15
+ "openai",
16
+ "git",
17
+ "husky"
18
+ ],
19
+ "type": "commonjs",
20
+ "main": "lib/commitlint-preset.cjs",
21
+ "exports": {
22
+ ".": "./lib/commitlint-preset.cjs",
23
+ "./rules": "./lib/rules.js"
24
+ },
25
+ "bin": {
26
+ "ai-commit": "bin/cli.js"
27
+ },
28
+ "files": [
29
+ "bin",
30
+ "lib",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "packageManager": "pnpm@10.11.0",
35
+ "engines": {
36
+ "node": ">=24.14.0"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "scripts": {
42
+ "prepare": "husky",
43
+ "commit": "node ./bin/cli.js run",
44
+ "lint:commit": "commitlint --edit .git/COMMIT_EDITMSG --config lib/commitlint-preset.cjs",
45
+ "open-pr": "node tools/open-pr.js",
46
+ "release": "semantic-release",
47
+ "prepublishOnly": "node -e \"require('./lib/rules.js'); require('./lib/commitlint-preset.cjs');\""
48
+ },
49
+ "dependencies": {
50
+ "@commitlint/cli": "^20.5.0",
51
+ "@commitlint/config-conventional": "^20.5.0",
52
+ "@commitlint/lint": "^20.5.0",
53
+ "@commitlint/load": "^20.5.0",
54
+ "@commitlint/parse": "^20.5.0",
55
+ "dotenv": "^16.4.7",
56
+ "openai": "^6.33.0"
57
+ },
58
+ "devDependencies": {
59
+ "@semantic-release/changelog": "^6.0.3",
60
+ "@semantic-release/commit-analyzer": "^13.0.1",
61
+ "@semantic-release/git": "^10.0.1",
62
+ "@semantic-release/github": "^12.0.6",
63
+ "@semantic-release/npm": "^13.1.5",
64
+ "husky": "^9.1.7",
65
+ "semantic-release": "^25.0.3"
66
+ }
67
+ }