diff --git a/.cursor/skills/laconic/agents/gemini.toml b/.cursor/skills/laconic/agents/gemini.toml index 0e65bcf..f33dd04 100644 --- a/.cursor/skills/laconic/agents/gemini.toml +++ b/.cursor/skills/laconic/agents/gemini.toml @@ -61,7 +61,7 @@ For the rest of this session, follow these rules in every response. The harness 4. Number multi-step work, with one bounded action per step. 5. Finish the current issue before raising a new one. 6. In multi-step work, say which steps are done and which comes next, using my numbering; do not mark a step done unless it is. -7. For errors, state the cause and the fix. +7. State errors plainly. Give the error, the known cause and the fix or the next check that would find the cause, with no 'Uh oh'. 8. Give estimates in concrete units, and only with a basis; otherwise say the time is unknown and what it depends on. Do not substitute typical ranges or industry figures. 9. Group long lists and rank them; never drop a relevant item when completeness matters. 10. No preamble, recap or closing pleasantries. End with one next action only when I must act. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1941657 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + # The pinned CLIs behind the load checks; see .github/ci/README.md. + - package-ecosystem: npm + directories: + - /.github/ci/claude-code + - /.github/ci/pi + schedule: + interval: weekly diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 91491d7..176e1a9 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,21 +8,13 @@ - [ ] **Autonomous agent-authored:** an agent planned and produced most of the substantive change. - [ ] **Hybrid:** a human and one or more agents both made substantive contributions. -**Agent/tool and model/version:** +**Agent/tool and model/version:** **Agent contribution:** **Human verification:** -**Known limitations or uncertain results:** - -## Labels - -**Target label:** - -**Author label:** - -**Workflow labels:** +**Known limitations or uncertain results:** ## Safety and side effects @@ -34,7 +26,7 @@ **Side effects, permissions, network access and cost:** - + ## Compatibility diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 532188d..aa3dfa4 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -18,13 +18,13 @@ jobs: actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 1 - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@6d5ae526d25ce88daa2200be073e1a373167f064 # v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/.github/workflows/cursor-skill-sync.yml b/.github/workflows/cursor-skill-sync.yml index 9888e45..82fe4be 100644 --- a/.github/workflows/cursor-skill-sync.yml +++ b/.github/workflows/cursor-skill-sync.yml @@ -16,7 +16,7 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Fail if the .cursor copy differs from skills run: | diff -r skills .cursor/skills || { diff --git a/.github/workflows/laconic-check-release.yml b/.github/workflows/laconic-check-release.yml index 8367c95..1bfd567 100644 --- a/.github/workflows/laconic-check-release.yml +++ b/.github/workflows/laconic-check-release.yml @@ -36,7 +36,7 @@ jobs: shell: bash working-directory: tools/laconic-check steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false - name: Check the tag matches the crate version @@ -68,7 +68,7 @@ jobs: tar -czf "../$name.tar.gz" ${{ matrix.binary }} cd .. && shasum -a 256 "$name.tar.gz" > "$name.tar.gz.sha256" fi - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: laconic-check-${{ matrix.target }} path: | @@ -83,7 +83,7 @@ jobs: permissions: contents: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: path: dist merge-multiple: true diff --git a/.github/workflows/laconic-check.yml b/.github/workflows/laconic-check.yml index af46f95..a5e556b 100644 --- a/.github/workflows/laconic-check.yml +++ b/.github/workflows/laconic-check.yml @@ -26,7 +26,7 @@ jobs: run: working-directory: tools/laconic-check steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false - name: Format, lint and test diff --git a/.github/workflows/pi-load-check.yml b/.github/workflows/pi-load-check.yml index e860c43..550f432 100644 --- a/.github/workflows/pi-load-check.yml +++ b/.github/workflows/pi-load-check.yml @@ -20,10 +20,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 - name: Install Pi diff --git a/.github/workflows/plugin-load-check.yml b/.github/workflows/plugin-load-check.yml index d7f8d95..0e55ac0 100644 --- a/.github/workflows/plugin-load-check.yml +++ b/.github/workflows/plugin-load-check.yml @@ -25,10 +25,10 @@ jobs: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.x" - name: Test native hook implementations @@ -37,7 +37,7 @@ jobs: load: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false - name: Install Claude Code diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2057f51..c3de791 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,12 +16,15 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.x" + # Without the binary, tests/test_mcp_server.py would skip. + - name: Build laconic-check + run: cargo build --locked --manifest-path tools/laconic-check/Cargo.toml - name: Unit tests run: python -m unittest discover -s tests -v - name: Validate evaluation cases diff --git a/AGENTS.md b/AGENTS.md index aed2180..17cabbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,9 +27,9 @@ Agents can access the complete project by reading repository-relative files afte | OpenCode | `.opencode/` | Auto-discovered plugin and command entry points for V1 (1.18.29+) and V2. | | Other runtimes | `qwen-extension.json`, `kimi.plugin.json`, `gemini-extension.json`, `GEMINI.md`, `plugin.json` | Qwen, Kimi, Gemini and additional plugin metadata. | | Writing references | `skills/laconic-prose/references/` | Writing, editing and audit modes, patterns, venues, Style Manual rules and regression cases. | -| Documentation | `README.md`, `INSTALL.md` | User-facing overview and installation. | +| Documentation | `README.md`, `INSTALL.md`, `CHANGELOG.md`, `SECURITY.md` | User-facing overview, installation, changes and vulnerability reporting. | | Verification | `tests/`, `scripts/` | Unit tests, compatibility checks and evaluation tooling. | -| Contribution workflow | `CONTRIBUTING.md`, `.github/pull_request_template.md` | Authorship, labels, safety, review and PR requirements. | +| Contribution workflow | `CONTRIBUTING.md`, `.github/pull_request_template.md` | Authorship, issues, safety, review and PR requirements. | ## Runtime entry points diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8d4a7f2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,85 @@ +# Changelog + +This file follows the shape of [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and is built from `git log`. The plugin and `laconic-check` are versioned separately. Dates are commit dates. Numbers in brackets are pull requests. + +The manifests still declare plugin 0.5.0 and `Cargo.toml` still declares `laconic-check` 0.2.0, so a build from `main` carries those versions plus the Unreleased changes. Only `v0.4.1` and `laconic-check-v0.1.0` are tagged; neither plugin 0.5.0 nor `laconic-check` 0.2.0 has a tag or release. + +## [Unreleased] + +### Plugin: added + +- Warn when always-on mode cannot locate the plugin (#18). +- Run the full Python test suite and eval validation in CI (#19). +- Run eval model calls in parallel with `--jobs` (#11). +- Benchmark Laconic against `humanizer` and `avoid-ai-writing` (#12). +- A revisit trigger for each shortcut marker (#24). +- A README badge row, CodeQL, `CITATION.cff` and `llms.txt` (#37). + +### Plugin: changed + +- Make Laconic a full replacement for stop-slop and No AI Slop. +- Forbid typical-range estimates in place of evidence. +- Move the compact rule's mechanics into a before-sending checklist (#34). +- Relicense under FSL-1.1-ALv2 (#25), replacing the Apache-2.0 licence adopted the same day (#22). +- Point repository links at EquityBot (#31). + +### Plugin: fixed + +- Validate eval inputs and record requested model metadata (#20). +- Make hook trimming linear (#29). + +### Plugin: security + +- Pin CI agent installs and disable lifecycle scripts (#28). +- Keep scenario prompts out of Claude CLI arguments and reject model values that could become options (#30). + +### laconic-check: added + +- Flag unnecessary hyphens (#13). +- Report protected items an edit added, failing on them only with `--strict` (#26). +- Report dropped quantifiers, modals and bounds in `preserve` (#35). + +### laconic-check: fixed + +- Catch prose patterns across wrapped Markdown lines (#16). +- Stop date checks from joining separate clauses (#17). +- Make dictionary parsing linear (#29). +- Stop flagging commas that join 2 clauses as serial commas (#36). + +## [laconic-check 0.2.0] - 2026-10-20 + +### Added + +- Flag staged contrasts and reveal questions (#10). + +## [laconic-check 0.1.0] - 2026-10-20 + +### Added + +- The `lint` and `preserve` commands (#2). +- The MCP server and seeded-fault measurement. +- Stock-phrase linting (#5). +- Flags for mismatched weekdays (#6). +- Prebuilt binaries published on a release tag (#8). + +### Changed + +- Make the serial-comma rule precise enough to act on (#7). + +## [plugin 0.5.0] - 2026-10-19 + +### Changed + +- Turn the i-have-adhd fork into Laconic: concise, answer-first output in correct Australian English, with the No AI Slop prose skill merged into the response rules. +- Rename every adapter, hook, flag, manifest, test and doc to `laconic`. + +### Removed + +- The stale translations, upstream eval results and logo. + +For versions before 0.5.0, see the history from tag `v0.4.1`. + +[Unreleased]: https://github.com/EquityBot/laconic/commits/main +[laconic-check 0.2.0]: https://github.com/EquityBot/laconic/commit/b633217 +[laconic-check 0.1.0]: https://github.com/EquityBot/laconic/releases/tag/laconic-check-v0.1.0 +[plugin 0.5.0]: https://github.com/EquityBot/laconic/commit/3eb0a03 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e97510f..c8a7523 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,15 +14,11 @@ For autonomous-agent or hybrid contributions, disclose the agent or tool and mod The submitting human remains accountable for the full diff. Before submission, read the changed files, remove unrelated generated changes and verify the claims in the PR description. -## Labels +## Issues and labels -Use one label from each applicable group: +Open an issue for a bug, a feature proposal or a question. Report suspected vulnerabilities privately, as `SECURITY.md` describes, not in an issue. -- **Target:** `Target:Integrations` for a CLI integration, `Target:Evals` for evaluation scripts, `Target:Rules` for skill rules, or `Target:Docs` for documentation and translations. -- **Author:** `Author:Human`, `Author:Hybrid` or `Author:AI`, matching the authorship category above. -- **Workflow:** `bug` for defects, `enhancement` for new features, `issue` for general issue tracking, `question` when more information is needed, or `duplicate` when the issue or PR already exists. - -Choose the labels that describe the change; do not use labels as a substitute for the PR description or provenance disclosure. Use `issue` only when no more specific workflow label applies. +Labels are optional and help triage: `bug` for defects, `enhancement` for new features, `documentation` for documentation, `question` when more information is needed and `duplicate` when the issue or PR already exists. A label does not replace the PR description or provenance disclosure. ## Scope and reviewability diff --git a/INSTALL.md b/INSTALL.md index 530cd51..a2cc168 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -351,7 +351,7 @@ rm ~/.gemini/commands/laconic.toml # command route
GitHub Copilot (VS Code and Copilot CLI) -Copilot reads Agent Skills natively: the same `SKILL.md`, no conversion. It scans `.github/skills/`, `.claude/skills/` and `.agents/skills/` in the project, and `~/.copilot/skills/`, `~/.claude/skills/`, and `~/.agents/skills/` globally. +Copilot reads Agent Skills natively: the same `SKILL.md`, no conversion. It scans `.github/skills/`, `.claude/skills/` and `.agents/skills/` in the project, and `~/.copilot/skills/` and `~/.agents/skills/` globally. VS Code also scans `~/.claude/skills/`; GitHub's Copilot CLI documentation does not list that path. ### Install diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2bb445c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +# Security policy + +Laconic runs code on your machine: its hooks and plugins start with your coding agent, and its MCP server launches a local binary. Please report suspected vulnerabilities privately. + +## Report a vulnerability + +Use GitHub's private vulnerability reporting: open the [Security tab](https://github.com/EquityBot/laconic/security) and choose 'Report a vulnerability'. If that option is not shown, private reporting is switched off for now: keep the details private and check back later. Do not open a public issue or PR for a suspected vulnerability. + +Include the affected file or command, the commit or release, the steps to reproduce and the impact you expect. + +Laconic has one maintainer, so handling is best effort and no response or fix time is guaranteed. A confirmed vulnerability is fixed on `main` as soon as the maintainer can manage and disclosed in a GitHub security advisory. Tell us in your report if you want credit. + +## Supported versions + +Only `main` is supported. Fixes land on `main` and reach the `laconic-check` binaries only through a later [release](https://github.com/EquityBot/laconic/releases). No published binary, including 0.1.0, will be patched or reissued, and no release date is promised. + +## In scope + +- Hooks in `hooks/`, which run when an agent session starts. +- Plugins and extensions in `.opencode/plugins/` and `extensions/`. +- `tools/laconic-check`: the CLI, `mcp/server.mjs` and the binaries on GitHub Releases. +- Install, update and uninstall commands in `INSTALL.md` that do more than they state. +- `scripts/` and `.github/workflows/`, where untrusted input could run code or expose a secret. +- Skill and rule text that tells an agent to do anything the safety list in `CONTRIBUTING.md` forbids, such as reading credentials or bypassing confirmation. + +## Out of scope + +- Vulnerabilities in the agent runtimes, models or third-party packages themselves. Report those to their vendors. +- A model that ignores or misapplies the rules. +- Findings `laconic-check` misses or flags wrongly, which are ordinary bugs unless they run code or expose data. +- Upstream projects Laconic derives from, including i-have-adhd. diff --git a/skills/laconic/agents/gemini.toml b/skills/laconic/agents/gemini.toml index 0e65bcf..f33dd04 100644 --- a/skills/laconic/agents/gemini.toml +++ b/skills/laconic/agents/gemini.toml @@ -61,7 +61,7 @@ For the rest of this session, follow these rules in every response. The harness 4. Number multi-step work, with one bounded action per step. 5. Finish the current issue before raising a new one. 6. In multi-step work, say which steps are done and which comes next, using my numbering; do not mark a step done unless it is. -7. For errors, state the cause and the fix. +7. State errors plainly. Give the error, the known cause and the fix or the next check that would find the cause, with no 'Uh oh'. 8. Give estimates in concrete units, and only with a basis; otherwise say the time is unknown and what it depends on. Do not substitute typical ranges or industry figures. 9. Group long lists and rank them; never drop a relevant item when completeness matters. 10. No preamble, recap or closing pleasantries. End with one next action only when I must act. diff --git a/tests/test_ci_installs.py b/tests/test_ci_installs.py index 5083e25..9e7dc37 100644 --- a/tests/test_ci_installs.py +++ b/tests/test_ci_installs.py @@ -63,6 +63,32 @@ def test_load_checks_do_not_retain_checkout_credentials(self): step = checkout.split("\n - ", 1)[0] self.assertIn("persist-credentials: false", step) + def test_workflow_actions_are_pinned_to_full_commit_shas(self): + workflows = (ROOT / ".github/workflows").iterdir() + for path in sorted(p for p in workflows if p.suffix in (".yml", ".yaml")): + text = path.read_text(encoding="utf8") + for use in re.findall(r"^\s*(?:- )?uses:\s*(.+?)\s*$", text, re.M): + with self.subTest(workflow=path.name, uses=use): + self.assertRegex(use, r"^[\w./-]+@[0-9a-f]{40} # v\d") + + def test_dependabot_covers_actions_and_every_ci_lockfile(self): + # The suite has no YAML parser, so match each line exactly, block by block. + config = (ROOT / ".github/dependabot.yml").read_text(encoding="utf8") + self.assertRegex(config, r"(?m)^version: 2$") + self.assertRegex(config, r"(?m)^updates:$") + pieces = re.split(r"(?m)^ - package-ecosystem: (\S+)$", config)[1:] + blocks = dict(zip(pieces[::2], pieces[1::2])) + self.assertEqual({"github-actions", "npm"}, set(blocks)) + for ecosystem, block in blocks.items(): + with self.subTest(ecosystem=ecosystem): + self.assertRegex(block, r"(?m)^ schedule:\n interval: \w+$") + self.assertRegex(blocks["github-actions"], r"(?m)^ directory: /$") + lockfiles = sorted((ROOT / ".github/ci").glob("*/package-lock.json")) + self.assertTrue(lockfiles) + for lock in lockfiles: + with self.subTest(directory=lock.parent.name): + self.assertRegex(blocks["npm"], rf"(?m)^ - /\.github/ci/{lock.parent.name}$") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_manifest_versions.py b/tests/test_manifest_versions.py new file mode 100644 index 0000000..3d199b8 --- /dev/null +++ b/tests/test_manifest_versions.py @@ -0,0 +1,58 @@ +"""Checks that every plugin manifest declares one version and CHANGELOG.md names it.""" + +import json +import pathlib +import re +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +VERSIONED = ( + ".claude-plugin/plugin.json", + ".codex-plugin/plugin.json", + "gemini-extension.json", + "qwen-extension.json", + "kimi.plugin.json", + "package.json", +) +# These declare no version today; if one ever does, it must agree too. +OPTIONAL = ( + ".claude-plugin/marketplace.json", + ".agents/plugins/marketplace.json", + "plugin.json", +) + + +def declared_versions() -> dict[str, str]: + versions = {} + for name in VERSIONED + OPTIONAL: + manifest = json.loads((ROOT / name).read_text(encoding="utf-8")) + if "version" in manifest: + versions[name] = manifest["version"] + for plugin in manifest.get("plugins", []): + if "version" in plugin: + versions[f"{name} ({plugin['name']})"] = plugin["version"] + return versions + + +class ManifestVersionTest(unittest.TestCase): + def test_declared_versions_agree(self): + versions = declared_versions() + self.assertEqual(1, len(set(versions.values())), versions) + + def test_versioned_manifests_declare_a_version(self): + versions = declared_versions() + for name in VERSIONED: + with self.subTest(manifest=name): + self.assertIn(name, versions) + + def test_changelog_names_the_current_versions(self): + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + plugin = next(iter(declared_versions().values())) + self.assertIn(f"## [plugin {plugin}] - ", changelog) + cargo = (ROOT / "tools/laconic-check/Cargo.toml").read_text(encoding="utf-8") + checker = re.search(r'^version = "([^"]+)"', cargo, re.M).group(1) + self.assertIn(f"## [laconic-check {checker}] - ", changelog) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000..584a4e7 --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,85 @@ +"""Smoke test for the laconic-check MCP server over stdio.""" + +import json +import os +import pathlib +import shutil +import subprocess +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SERVER = ROOT / "tools/laconic-check/mcp/server.mjs" + + +def find_binary(): + # Prefer this checkout's build, so a stale installed copy cannot hide a regression. + return ( + os.environ.get("LACONIC_CHECK") + or shutil.which(ROOT / "tools/laconic-check/target/debug/laconic-check") + or shutil.which("laconic-check") + ) + + +@unittest.skipUnless(shutil.which("node"), "MCP smoke test not run: node is not on the PATH") +@unittest.skipUnless( + find_binary(), + "MCP smoke test not run: no laconic-check binary in LACONIC_CHECK, target/debug or the PATH", +) +class McpServerTest(unittest.TestCase): + def exchange(self, *requests): + """Send the requests and return the server's replies keyed by id.""" + result = subprocess.run( + ["node", str(SERVER)], + input="".join(json.dumps({"jsonrpc": "2.0", **r}) + "\n" for r in requests), + capture_output=True, + encoding="utf-8", + env={**os.environ, "LACONIC_CHECK": str(find_binary())}, + timeout=60, + check=False, + ) + self.assertEqual(0, result.returncode, result.stderr) + replies = [json.loads(line) for line in result.stdout.splitlines()] + return {reply["id"]: reply for reply in replies} + + def call(self, name, **arguments): + reply = self.exchange( + {"id": 1, "method": "tools/call", "params": {"name": name, "arguments": arguments}} + )[1] + self.assertNotIn("error", reply) + return reply["result"] + + def test_initialise_then_list_tools(self): + replies = self.exchange( + {"id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}}, + {"method": "notifications/initialized"}, + {"id": 2, "method": "tools/list"}, + ) + self.assertEqual({1, 2}, set(replies), "a notification must get no reply") + self.assertEqual("laconic-check", replies[1]["result"]["serverInfo"]["name"]) + self.assertEqual("2025-06-18", replies[1]["result"]["protocolVersion"]) + tools = {tool["name"]: tool for tool in replies[2]["result"]["tools"]} + self.assertEqual({"lint", "preserve"}, set(tools)) + self.assertEqual(["text"], tools["lint"]["inputSchema"]["required"]) + self.assertEqual(["original", "edited"], tools["preserve"]["inputSchema"]["required"]) + + def test_lint_reports_a_finding_by_line(self): + result = self.call("lint", text="Clean line.\n\nIt worked — mostly.\n") + self.assertNotIn("isError", result) + self.assertRegex(result["content"][0]["text"], r"(?m)^line 3:") + + def test_lint_reports_clean_text(self): + result = self.call("lint", text="It worked.\n") + self.assertEqual("No findings.", result["content"][0]["text"]) + + def test_preserve_reports_a_dropped_number(self): + result = self.call("preserve", original="Keep 42 items.\n", edited="Keep items.\n") + self.assertNotIn("isError", result) + self.assertIn("42", result["content"][0]["text"]) + + def test_tool_errors_are_reported_in_the_result(self): + result = self.call("lint", text=1) + self.assertTrue(result["isError"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_skills.py b/tests/test_skills.py index ba21892..f5d95c5 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -46,6 +46,14 @@ def test_no_em_dashes_in_skill_files_or_compact_rules(self): with self.subTest(file=path.name): self.assertNotIn("\u2014", path.read_text(encoding="utf-8")) + def test_gemini_command_keeps_the_canonical_error_rule(self): + skill = (SKILLS / "laconic" / "SKILL.md").read_text(encoding="utf-8") + title, rule = re.search(r"^7\. \*\*(State errors plainly\.)\*\* (.+)$", skill, re.M).groups() + relative = pathlib.Path("skills/laconic/agents/gemini.toml") + for path in [ROOT / relative, ROOT / ".cursor" / relative]: + with self.subTest(file=str(path.relative_to(ROOT))): + self.assertIn(f"7. {title} {rule}\n", path.read_text(encoding="utf-8")) + if __name__ == "__main__": unittest.main()