agency-rules 0.0.0-stage → 0.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.
- package/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.md +144 -2
- package/docs/ADDING-A-HARNESS.md +170 -0
- package/docs/ARCHITECTURE.md +130 -0
- package/docs/RULE-SPEC.md +135 -0
- package/package.json +34 -4
- package/rules/_schema.json +65 -0
- package/scripts/convert.mjs +106 -0
- package/scripts/install.mjs +142 -0
- package/scripts/lib/detect.mjs +32 -0
- package/scripts/lib/frontmatter.mjs +37 -0
- package/scripts/lib/renderers/_util.mjs +8 -0
- package/scripts/lib/renderers/antigravity.mjs +30 -0
- package/scripts/lib/renderers/index.mjs +12 -0
- package/scripts/lib/renderers/oh-my-pi.mjs +16 -0
- package/scripts/lib/renderers/opencode.mjs +13 -0
- package/scripts/lib/renderers/pi-agent.mjs +17 -0
- package/scripts/validate.mjs +52 -0
- package/tools.json +91 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Initial scaffold.
|
|
13
|
+
- Conversion engine driven by `tools.json`.
|
|
14
|
+
- Renderers for **Google Antigravity**, **pi-agent**, **oh-my-pi (omp)**,
|
|
15
|
+
and **OpenCode** (via `opencode-rules`).
|
|
16
|
+
- `mise.toml` with toolchain (`node@22`), tasks (`validate`, `convert`,
|
|
17
|
+
`install`, `test`, `ci`), and post-install hook.
|
|
18
|
+
- Test suites for frontmatter parsing and every renderer.
|
|
19
|
+
- Documentation: architecture, rule spec, harness extension guide.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- N/A
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- N/A
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## [0.1.0] — TBD
|
|
32
|
+
|
|
33
|
+
First tagged release. Reserved for the v0.1.0 cut once the harness set is
|
|
34
|
+
stable.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Deuri Vasquez
|
|
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
CHANGED
|
@@ -1,3 +1,145 @@
|
|
|
1
|
-
#
|
|
1
|
+
# agency-rules
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Write rules once. Run them everywhere.**
|
|
4
|
+
|
|
5
|
+
`agency-rules` compiles a single set of generic Markdown rules into the
|
|
6
|
+
native formats expected by **Google Antigravity**, **pi-agent**,
|
|
7
|
+
**oh-my-pi (omp)**, and **OpenCode** (via the `opencode-rules` plugin).
|
|
8
|
+
|
|
9
|
+
The whole pipeline — toolchain, environment, and tasks — is managed by
|
|
10
|
+
[mise](https://mise.jdx.dev), so every collaborator and CI runner uses the
|
|
11
|
+
exact same Node version and the exact same commands.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Why
|
|
16
|
+
|
|
17
|
+
Every coding agent has its own rule format:
|
|
18
|
+
|
|
19
|
+
| Harness | Format | Location |
|
|
20
|
+
| --- | --- | --- |
|
|
21
|
+
| Google Antigravity | YAML frontmatter with `trigger` | `.agents/rules/*.md` |
|
|
22
|
+
| pi-agent | Per-rule directory with `RULES.md` | `.pi/rules/<slug>/RULES.md` |
|
|
23
|
+
| oh-my-pi (omp) | Markdown with frontmatter | `.omp/rules/*.{md,mdc}` |
|
|
24
|
+
| OpenCode | Markdown via `opencode-rules` plugin | `.opencode/rules/*.md` |
|
|
25
|
+
|
|
26
|
+
Writing the same rule four times is a maintenance nightmare. `agency-rules`
|
|
27
|
+
lets you write it **once** in a neutral format and compile it to all four
|
|
28
|
+
with a single command, driven by a declarative contract (`tools.json`).
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# 1. Install toolchain + dependencies (one time)
|
|
36
|
+
git clone https://github.com/<your-org>/agency-rules.git
|
|
37
|
+
cd agency-rules
|
|
38
|
+
mise install
|
|
39
|
+
|
|
40
|
+
# 2. Compile rules to every harness
|
|
41
|
+
mise run convert
|
|
42
|
+
|
|
43
|
+
# 3. Install into harnesses detected on your machine
|
|
44
|
+
mise run install --scope project
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
That's it. Your rules are now live in `.agents/`, `.pi/`, `.omp/`, and
|
|
48
|
+
`.opencode/` (and the `opencode-rules` plugin is registered in
|
|
49
|
+
`opencode.json` automatically).
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Repository layout
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
agency-rules/
|
|
57
|
+
├── rules/ ← source of truth (Markdown + YAML frontmatter)
|
|
58
|
+
├── integrations/ ← compiled output (generated, gitignored)
|
|
59
|
+
├── scripts/ ← convert, install, validate + renderers
|
|
60
|
+
├── tests/ ← node:test suites for renderers and parsing
|
|
61
|
+
├── docs/ ← architecture, spec, extension guide
|
|
62
|
+
├── tools.json ← the conversion contract
|
|
63
|
+
└── mise.toml ← toolchain + tasks
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
1. **Validate**: `mise run validate`.
|
|
69
|
+
2. **Convert**: `mise run convert` and inspect `integrations/`.
|
|
70
|
+
3. **Test**: `mise run test`.
|
|
71
|
+
|
|
72
|
+
## Commands
|
|
73
|
+
|
|
74
|
+
All commands go through mise:
|
|
75
|
+
|
|
76
|
+
| Command | What it does |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| `mise run setup` | Install Node dependencies (`npm ci`) |
|
|
79
|
+
| `mise run validate` | Validate every rule's frontmatter against `rules/_schema.json` |
|
|
80
|
+
| `mise run check` | Format + lint + organize imports with Biome (write mode) |
|
|
81
|
+
| `mise run convert` | Compile `rules/` → `integrations/<harness>/` |
|
|
82
|
+
| `mise run convert:dry-run` | Preview which files `convert` would write (no writes) |
|
|
83
|
+
| `mise run install` | Copy compiled output into harness directories |
|
|
84
|
+
| `mise run install:dry-run` | Preview which files `install` would copy (no writes) |
|
|
85
|
+
| `mise run test` | Run the Node test suite |
|
|
86
|
+
| `mise run ci` | Full pipeline (`validate` → `check` → `convert` → `test`) |
|
|
87
|
+
|
|
88
|
+
See `mise tasks` for the up-to-date list.
|
|
89
|
+
|
|
90
|
+
### Dry-run workflow
|
|
91
|
+
|
|
92
|
+
The typical inner loop uses dry-runs first to inspect what will change
|
|
93
|
+
before anything hits disk:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
mise run convert:dry-run # see generated file paths + sizes
|
|
97
|
+
mise run convert # actually write to integrations/
|
|
98
|
+
mise run install:dry-run -- --all # see copy destinations
|
|
99
|
+
mise run install -- --all # actually copy into harness dirs
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Adding a rule
|
|
105
|
+
|
|
106
|
+
1. Create `rules/<category>/<slug>.md` following
|
|
107
|
+
[`docs/RULE-SPEC.md`](docs/RULE-SPEC.md).
|
|
108
|
+
2. Run `mise run validate` — it must pass.
|
|
109
|
+
3. Run `mise run convert` — inspect `integrations/` to sanity-check output.
|
|
110
|
+
4. Open a PR.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Adding a harness
|
|
115
|
+
|
|
116
|
+
See [`docs/ADDING-A-HARNESS.md`](docs/ADDING-A-HARNESS.md). It's roughly:
|
|
117
|
+
|
|
118
|
+
1. Add an entry in `tools.json`.
|
|
119
|
+
2. Add a renderer in `scripts/lib/renderers/`.
|
|
120
|
+
3. Register the renderer in `scripts/lib/renderers/index.mjs`.
|
|
121
|
+
4. Add tests in `tests/convert.test.mjs`.
|
|
122
|
+
|
|
123
|
+
The conversion engine itself never changes.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Documentation
|
|
128
|
+
|
|
129
|
+
- [Architecture](docs/ARCHITECTURE.md) — data flow, contracts, caching.
|
|
130
|
+
- [Rule spec](docs/RULE-SPEC.md) — the neutral frontmatter format.
|
|
131
|
+
- [Adding a harness](docs/ADDING-A-HARNESS.md) — extension guide.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Contributing
|
|
136
|
+
|
|
137
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). Pull requests welcome.
|
|
138
|
+
|
|
139
|
+
## Security
|
|
140
|
+
|
|
141
|
+
See [SECURITY.md](SECURITY.md) for responsible disclosure.
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Adding a Harness
|
|
2
|
+
|
|
3
|
+
This guide walks through adding support for a new coding agent. The example
|
|
4
|
+
uses a hypothetical harness called **`example-agent`**.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Prerequisites
|
|
9
|
+
|
|
10
|
+
Read [`ARCHITECTURE.md`](ARCHITECTURE.md) first. You need to understand:
|
|
11
|
+
|
|
12
|
+
- Why `tools.json` is a contract (not a list).
|
|
13
|
+
- What renderers do (pure functions, no I/O).
|
|
14
|
+
- What the installer does (copies per manifest).
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Step 1 — Add an entry to `tools.json`
|
|
19
|
+
|
|
20
|
+
Add a new key under `harnesses`. Every field matters:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
"example-agent": {
|
|
24
|
+
"id": "example-agent",
|
|
25
|
+
"label": "Example Agent",
|
|
26
|
+
"format": "md-example",
|
|
27
|
+
"installKind": "per-rule",
|
|
28
|
+
"integrationPath": "{{slug}}.md",
|
|
29
|
+
"detect": { "dirs": [".example", "~/.example"] },
|
|
30
|
+
"dest": {
|
|
31
|
+
"project": ".example/rules/{{slug}}.md",
|
|
32
|
+
"user": "~/.example/rules/{{slug}}.md"
|
|
33
|
+
},
|
|
34
|
+
"frontmatter": {
|
|
35
|
+
"required": ["description"],
|
|
36
|
+
"map": {
|
|
37
|
+
"description": "string",
|
|
38
|
+
"globs": "array<string>|string"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Field checklist:
|
|
45
|
+
|
|
46
|
+
- [ ] `id` — matches the key, kebab-case.
|
|
47
|
+
- [ ] `label` — human-readable name.
|
|
48
|
+
- [ ] `format` — you will use this string as a key in `renderers/index.mjs`.
|
|
49
|
+
- [ ] `installKind` — currently only `per-rule` is implemented.
|
|
50
|
+
- [ ] `integrationPath` — path under `integrations/<id>/`. Use `{{slug}}`.
|
|
51
|
+
- [ ] `detect.dirs` / `detect.files` — where the harness lives.
|
|
52
|
+
- [ ] `dest.project` / `dest.user` — install destinations.
|
|
53
|
+
- [ ] `frontmatter.required` — fields the harness cannot run without.
|
|
54
|
+
- [ ] `frontmatter.map` — documentation of supported fields.
|
|
55
|
+
- [ ] `plugin` — only if the harness needs a plugin registered.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Step 2 — Create the renderer
|
|
60
|
+
|
|
61
|
+
Create `scripts/lib/renderers/example-agent.mjs`:
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
import { wrap } from './_util.mjs';
|
|
65
|
+
|
|
66
|
+
export function renderExampleAgent(rule, _harness) {
|
|
67
|
+
const d = rule.data;
|
|
68
|
+
const fm = { description: d.description };
|
|
69
|
+
if (d.globs !== undefined) fm.globs = d.globs;
|
|
70
|
+
return wrap(fm, rule.body);
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Rules for renderers:
|
|
75
|
+
|
|
76
|
+
- Pure. No `fs`, no `process`, no globals.
|
|
77
|
+
- Signature is `(rule, harness) => string`.
|
|
78
|
+
- Read only the fields your harness understands. Drop the rest silently —
|
|
79
|
+
other harnesses may need them.
|
|
80
|
+
- Use `wrap()` from `_util.mjs` for consistent frontmatter formatting.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Step 3 — Register the renderer
|
|
85
|
+
|
|
86
|
+
Edit `scripts/lib/renderers/index.mjs`:
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
import { renderExampleAgent } from './example-agent.mjs';
|
|
90
|
+
|
|
91
|
+
export const renderers = {
|
|
92
|
+
// ...
|
|
93
|
+
'md-example': renderExampleAgent,
|
|
94
|
+
};
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The key must be the exact string from `tools.json`'s `format` field.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Step 4 — Add tests
|
|
102
|
+
|
|
103
|
+
Edit `tests/convert.test.mjs` and add a block:
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
import { renderExampleAgent } from '../scripts/lib/renderers/example-agent.mjs';
|
|
107
|
+
|
|
108
|
+
test('example-agent: emits description', () => {
|
|
109
|
+
const fm = matter(renderExampleAgent(baseRule)).data;
|
|
110
|
+
assert.equal(fm.description, 'Rules for TS.');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('example-agent: preserves globs as-is', () => {
|
|
114
|
+
const fm = matter(renderExampleAgent(baseRule)).data;
|
|
115
|
+
assert.deepEqual(fm.globs, ['**/*.ts', '**/*.tsx']);
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Cover:
|
|
120
|
+
|
|
121
|
+
- A rule with only the required fields.
|
|
122
|
+
- A rule with every optional field your harness supports.
|
|
123
|
+
- The "body preserved verbatim" test in the shared block.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Step 5 — Verify end-to-end
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
mise run validate # schema must still pass
|
|
131
|
+
mise run convert # your harness should appear in the summary
|
|
132
|
+
ls integrations/example-agent/
|
|
133
|
+
mise run test # all renderer tests, including yours
|
|
134
|
+
mise run install --harness example-agent --scope project
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Inspect `integrations/example-agent/<slug>.md` and confirm the output matches
|
|
138
|
+
your harness's expected format.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Step 6 — Update docs
|
|
143
|
+
|
|
144
|
+
- Add a row to the compatibility matrix in `docs/RULE-SPEC.md`.
|
|
145
|
+
- Add a row to the harness table in `README.md`.
|
|
146
|
+
- Update the "Adding a rule — checklist" in `CONTRIBUTING.md` if the new
|
|
147
|
+
harness changes requirements.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Step 7 — Open a PR
|
|
152
|
+
|
|
153
|
+
Title: `feat(harness): support example-agent`
|
|
154
|
+
|
|
155
|
+
Include in the description:
|
|
156
|
+
|
|
157
|
+
- Link to the harness's documentation.
|
|
158
|
+
- A sample compiled output.
|
|
159
|
+
- Screenshot or log from `mise run install` (optional but helpful).
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Supported harnesses (current)
|
|
164
|
+
|
|
165
|
+
| Harness | `format` | `installKind` | Path |
|
|
166
|
+
| --- | --- | --- | --- |
|
|
167
|
+
| Google Antigravity | `md-yaml-trigger` | `per-rule` | `.agents/rules/*.md` |
|
|
168
|
+
| pi-agent | `md-rules-dir` | `per-rule` | `.pi/rules/<slug>/RULES.md` |
|
|
169
|
+
| oh-my-pi (omp) | `md-or-mdc` | `per-rule` | `.omp/rules/*.md` |
|
|
170
|
+
| OpenCode | `md-plugin` | `per-rule` | `.opencode/rules/*.md` |
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
This document explains how `agency-rules` turns one set of generic rules into
|
|
4
|
+
four native harness formats, and why the pipeline is shaped the way it is.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Data flow
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
rules/*.md tools.json
|
|
12
|
+
(source of truth) (contract)
|
|
13
|
+
│ │
|
|
14
|
+
│ │
|
|
15
|
+
▼ ▼
|
|
16
|
+
┌────────────────────────────────────────────┐
|
|
17
|
+
│ scripts/validate.mjs (schema + uniqueness)│
|
|
18
|
+
└────────────────────────────────────────────┘
|
|
19
|
+
│
|
|
20
|
+
▼
|
|
21
|
+
┌────────────────────────────────────────────┐
|
|
22
|
+
│ scripts/convert.mjs │
|
|
23
|
+
│ for each harness: │
|
|
24
|
+
│ render = renderers[harness.format] │
|
|
25
|
+
│ for each rule: │
|
|
26
|
+
│ write render(rule, harness) │
|
|
27
|
+
│ write _manifest.json │
|
|
28
|
+
└────────────────────────────────────────────┘
|
|
29
|
+
│
|
|
30
|
+
▼
|
|
31
|
+
integrations/<harness>/...
|
|
32
|
+
(compiled, gitignored)
|
|
33
|
+
│
|
|
34
|
+
▼
|
|
35
|
+
┌────────────────────────────────────────────┐
|
|
36
|
+
│ scripts/install.mjs │
|
|
37
|
+
│ detect harnesses │
|
|
38
|
+
│ copy files to harness.dest[scope] │
|
|
39
|
+
│ register plugins (opencode) │
|
|
40
|
+
└────────────────────────────────────────────┘
|
|
41
|
+
│
|
|
42
|
+
▼
|
|
43
|
+
user's harness dirs
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Layers
|
|
49
|
+
|
|
50
|
+
| Layer | File(s) | Responsibility |
|
|
51
|
+
| --- | --- | --- |
|
|
52
|
+
| **Source** | `rules/**/*.md` | Generic Markdown + YAML frontmatter. |
|
|
53
|
+
| **Schema** | `rules/_schema.json` | JSON Schema enforcing the neutral format. |
|
|
54
|
+
| **Contract** | `tools.json` | Declares each harness: format, integration path, install destinations, frontmatter mapping, plugin metadata. |
|
|
55
|
+
| **Engine** | `scripts/convert.mjs` | Iterates over rules × harnesses, calls the right renderer, writes output + manifest. |
|
|
56
|
+
| **Renderers** | `scripts/lib/renderers/*.mjs` | Pure functions: `(rule, harness) → markdown string`. No I/O. |
|
|
57
|
+
| **Installer** | `scripts/install.mjs` | Detects harnesses, copies compiled files to real destinations, registers plugins. |
|
|
58
|
+
| **Validator** | `scripts/validate.mjs` | Runs the schema and checks slug uniqueness before anything else runs. |
|
|
59
|
+
| **Orchestration** | `mise.toml` | Pins Node version, defines tasks, composes `ci`, wires the `postinstall` hook. |
|
|
60
|
+
|
|
61
|
+
Each layer has exactly one job. Renderers never touch the filesystem; the
|
|
62
|
+
engine never hardcodes a harness; the installer never renders.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Why `tools.json` is the single source of truth
|
|
67
|
+
|
|
68
|
+
Every harness-specific decision lives there:
|
|
69
|
+
|
|
70
|
+
- **Which renderer** to use (`format`).
|
|
71
|
+
- **What path** the output takes inside `integrations/<harness>/` (`integrationPath`).
|
|
72
|
+
- **Where** the file ultimately lands for `project` and `user` scopes (`dest`).
|
|
73
|
+
- **Which frontmatter fields** are required and how they map (`frontmatter`).
|
|
74
|
+
- **Whether** plugin registration is needed (`plugin`).
|
|
75
|
+
|
|
76
|
+
Adding a harness is additive: one `tools.json` entry + one renderer +
|
|
77
|
+
one test. The engine, installer, and validator never change.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Determinism guarantees
|
|
82
|
+
|
|
83
|
+
- **Pure renderers.** Same input → same output byte for byte. No timestamps,
|
|
84
|
+
no random ordering. Manifests are sorted by slug.
|
|
85
|
+
- **Full reset on convert.** `integrations/<harness>/` is deleted before
|
|
86
|
+
being rewritten. No stale files survive a rename.
|
|
87
|
+
- **Manifest as index.** `integrations/<harness>/_manifest.json` records
|
|
88
|
+
every file the installer should copy. The installer trusts the manifest,
|
|
89
|
+
not a directory walk.
|
|
90
|
+
- **Locked toolchain.** `mise.lock` pins exact versions of Node and any
|
|
91
|
+
installed CLIs. CI uses `mise install --locked` to fail on drift.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Caching
|
|
96
|
+
|
|
97
|
+
`mise run convert` declares:
|
|
98
|
+
|
|
99
|
+
```toml
|
|
100
|
+
sources = ["rules/**/*.md", "tools.json", "scripts/lib/**/*.mjs"]
|
|
101
|
+
outputs = ["integrations/**/*"]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
If no source has changed since the last run, mise skips the task entirely.
|
|
105
|
+
This keeps the inner loop fast as the rules tree grows.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## OpenCode plugin registration
|
|
110
|
+
|
|
111
|
+
OpenCode needs `opencode-rules` registered in `opencode.json`. The installer:
|
|
112
|
+
|
|
113
|
+
1. Reads (or creates) `opencode.json`.
|
|
114
|
+
2. Ensures `config.plugin` is an array.
|
|
115
|
+
3. Appends `"opencode-rules@latest"` if it isn't already present.
|
|
116
|
+
4. Writes the config back with 2-space indentation.
|
|
117
|
+
|
|
118
|
+
If the file exists but is invalid JSON, the installer warns and skips rather
|
|
119
|
+
than overwriting — a corrupted config is never silently clobbered.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Security considerations
|
|
124
|
+
|
|
125
|
+
- Rule slugs are validated against `^[a-z][a-z0-9-]*$` before they reach the
|
|
126
|
+
filesystem, preventing path traversal via `{{slug}}` substitution.
|
|
127
|
+
- `install.mjs` only reads from `integrations/` and writes to paths derived
|
|
128
|
+
from `tools.json` — it never reads arbitrary user input paths.
|
|
129
|
+
- Renderers do not execute rule content; they only stringify it. No `eval`,
|
|
130
|
+
no shell, no dynamic imports.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Rule Specification
|
|
2
|
+
|
|
3
|
+
The neutral format for a rule: **Markdown with YAML frontmatter**.
|
|
4
|
+
Every rule compiles to every harness, so the frontmatter must carry enough
|
|
5
|
+
information to derive each harness's expectations.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## File layout
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
rules/
|
|
13
|
+
├── _schema.json
|
|
14
|
+
├── <category>/
|
|
15
|
+
│ └── <slug>.md
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- `_schema.json` is the JSON Schema. It is not a rule and is skipped.
|
|
19
|
+
- Any file starting with `_` is skipped.
|
|
20
|
+
- `<slug>` should equal the frontmatter `name`.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Frontmatter fields
|
|
25
|
+
|
|
26
|
+
| Field | Required | Type | Description |
|
|
27
|
+
| --- | --- | --- | --- |
|
|
28
|
+
| `name` | ✅ | string, `^[a-z][a-z0-9-]*$`, ≤64 | Unique identifier. Used as the slug. |
|
|
29
|
+
| `description` | ✅ | string, 1–1024 | **When** the rule applies (not what it says). Harnesses use this for matching. |
|
|
30
|
+
| `globs` | ❌ | string \| string[] | File patterns that trigger the rule. |
|
|
31
|
+
| `alwaysApply` | ❌ | boolean | If `true`, ignore `globs` and always apply. |
|
|
32
|
+
| `trigger` | ❌ | `always_on` \| `glob` \| `model_decision` \| `manual` | Explicit trigger for Antigravity. Overrides auto-derivation. |
|
|
33
|
+
| `match` | ❌ | `any` \| `all` | OpenCode: match any glob or all globs. |
|
|
34
|
+
| `tags` | ❌ | string[] | Freeform metadata. Ignored by all harnesses. |
|
|
35
|
+
| `tools` | ❌ | string[] | pi-agent: tools the rule provides. Preserved verbatim. |
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Body
|
|
40
|
+
|
|
41
|
+
The body is Markdown. It is copied verbatim into every compiled output.
|
|
42
|
+
Don't put harness-specific syntax here — anything that must differ goes in
|
|
43
|
+
the frontmatter and is handled by the renderer.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Compatibility matrix
|
|
48
|
+
|
|
49
|
+
| Field | Antigravity | pi-agent | oh-my-pi | OpenCode |
|
|
50
|
+
| --- | --- | --- | --- | --- |
|
|
51
|
+
| `name` | (derived) | ✅ used | ✅ used | — |
|
|
52
|
+
| `description` | ✅ emitted | ✅ emitted | ✅ emitted | ✅ used for matching |
|
|
53
|
+
| `globs` | ✅ joined as string under `trigger: glob` | ✗ dropped | ✅ preserved | ✅ preserved |
|
|
54
|
+
| `alwaysApply` | ✅ → `trigger: always_on` | ✗ dropped | ✅ preserved | ✗ dropped |
|
|
55
|
+
| `trigger` | ✅ emitted | ✗ dropped | ✗ dropped | ✗ dropped |
|
|
56
|
+
| `match` | ✗ dropped | ✗ dropped | ✗ dropped | ✅ preserved |
|
|
57
|
+
| `tags` | ✗ dropped | ✗ dropped | ✗ dropped | ✗ dropped |
|
|
58
|
+
| `tools` | ✗ dropped | ✅ preserved | ✗ dropped | ✗ dropped |
|
|
59
|
+
|
|
60
|
+
"✗ dropped" means the field is not emitted in that harness's output. It does
|
|
61
|
+
not mean it's an error to set it — other harnesses may need it. The renderer
|
|
62
|
+
picks what its harness understands.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Antigravity trigger precedence
|
|
67
|
+
|
|
68
|
+
Antigravity requires a `trigger` field. The renderer resolves it in this
|
|
69
|
+
order:
|
|
70
|
+
|
|
71
|
+
1. Explicit `trigger` in the rule.
|
|
72
|
+
2. `alwaysApply: true` → `always_on`.
|
|
73
|
+
3. `globs` present → `glob`.
|
|
74
|
+
4. Only `description` → `model_decision`.
|
|
75
|
+
5. Nothing → `manual`.
|
|
76
|
+
|
|
77
|
+
When `trigger: glob`, the renderer joins the `globs` array with commas into a
|
|
78
|
+
single string, which is the format Antigravity's YAML parser expects.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Examples
|
|
83
|
+
|
|
84
|
+
### Always-on baseline
|
|
85
|
+
|
|
86
|
+
```markdown
|
|
87
|
+
---
|
|
88
|
+
name: coding-standards
|
|
89
|
+
description: Baseline rules applied to every edit regardless of file type.
|
|
90
|
+
alwaysApply: true
|
|
91
|
+
tags: [base]
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
# Coding Standards
|
|
95
|
+
...
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Compiles to Antigravity `trigger: always_on`, oh-my-pi `alwaysApply: true`,
|
|
99
|
+
and pi-agent / OpenCode with just `name` + `description`.
|
|
100
|
+
|
|
101
|
+
### Scoped to file globs
|
|
102
|
+
|
|
103
|
+
```markdown
|
|
104
|
+
---
|
|
105
|
+
name: typescript
|
|
106
|
+
description: Mandatory rules for editing TypeScript and TSX files.
|
|
107
|
+
globs:
|
|
108
|
+
- "**/*.ts"
|
|
109
|
+
- "**/*.tsx"
|
|
110
|
+
tags: [language, typescript]
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
# TypeScript Rules
|
|
114
|
+
...
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Compiles to Antigravity `trigger: glob` + `globs: "**/*.ts,**/*.tsx"`,
|
|
118
|
+
oh-my-pi and OpenCode with the original array, pi-agent without `globs`.
|
|
119
|
+
|
|
120
|
+
### Description-only (model decision)
|
|
121
|
+
|
|
122
|
+
```markdown
|
|
123
|
+
---
|
|
124
|
+
name: api-review
|
|
125
|
+
description: Use this rule when reviewing REST API endpoint definitions.
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
# API Review
|
|
129
|
+
...
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Compiles to Antigravity `trigger: model_decision`. Other harnesses get just
|
|
133
|
+
`name` + `description`.
|
|
134
|
+
|
|
135
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agency-rules",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Write rules once. Run them in every harness.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Deuri Vasquez <deuriib@gmail.com>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/deuriib/agency-rules.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/deuriib/agency-rules/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/deuriib/agency-rules#readme",
|
|
16
|
+
"keywords": ["ai", "agents", "rules", "antigravity", "pi-agent", "opencode", "oh-my-pi"],
|
|
17
|
+
"engines": { "node": ">=20.12" },
|
|
18
|
+
"files": [
|
|
19
|
+
"scripts/",
|
|
20
|
+
"tools.json",
|
|
21
|
+
"rules/_schema.json",
|
|
22
|
+
"docs/",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"CHANGELOG.md"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public",
|
|
29
|
+
"provenance": true
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"ajv": "^8.17.1",
|
|
33
|
+
"gray-matter": "^4.0.3",
|
|
34
|
+
"yaml": "^2.5.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"required": [
|
|
5
|
+
"name",
|
|
6
|
+
"description"
|
|
7
|
+
],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"name": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"pattern": "^[a-z][a-z0-9-]*$",
|
|
13
|
+
"maxLength": 64
|
|
14
|
+
},
|
|
15
|
+
"description": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"minLength": 1,
|
|
18
|
+
"maxLength": 1024
|
|
19
|
+
},
|
|
20
|
+
"globs": {
|
|
21
|
+
"oneOf": [
|
|
22
|
+
{
|
|
23
|
+
"type": "string",
|
|
24
|
+
"minLength": 1
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"type": "array",
|
|
28
|
+
"items": {
|
|
29
|
+
"type": "string"
|
|
30
|
+
},
|
|
31
|
+
"minItems": 1
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"alwaysApply": {
|
|
36
|
+
"type": "boolean"
|
|
37
|
+
},
|
|
38
|
+
"trigger": {
|
|
39
|
+
"enum": [
|
|
40
|
+
"always_on",
|
|
41
|
+
"glob",
|
|
42
|
+
"model_decision",
|
|
43
|
+
"manual"
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
"match": {
|
|
47
|
+
"enum": [
|
|
48
|
+
"any",
|
|
49
|
+
"all"
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
"tags": {
|
|
53
|
+
"type": "array",
|
|
54
|
+
"items": {
|
|
55
|
+
"type": "string"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"tools": {
|
|
59
|
+
"type": "array",
|
|
60
|
+
"items": {
|
|
61
|
+
"type": "string"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { readRules } from './lib/frontmatter.mjs';
|
|
5
|
+
import { renderers } from './lib/renderers/index.mjs';
|
|
6
|
+
|
|
7
|
+
const { values: args } = parseArgs({
|
|
8
|
+
options: {
|
|
9
|
+
'dry-run': { type: 'boolean', default: false },
|
|
10
|
+
harness: { type: 'string' },
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const dryRun = args['dry-run'];
|
|
15
|
+
const prefix = dryRun ? ' [dry-run]' : ' ';
|
|
16
|
+
|
|
17
|
+
const RULES_DIR = process.env.RULES_DIR || 'rules';
|
|
18
|
+
const INTEGRATIONS_DIR = process.env.INTEGRATIONS_DIR || 'integrations';
|
|
19
|
+
|
|
20
|
+
const tools = JSON.parse(await readFile('tools.json', 'utf8'));
|
|
21
|
+
const rules = await readRules(RULES_DIR);
|
|
22
|
+
|
|
23
|
+
if (rules.length === 0) {
|
|
24
|
+
console.error(`❌ No rules found in ${RULES_DIR}`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
console.log(
|
|
29
|
+
`📚 Found ${rules.length} rule(s) in ${RULES_DIR}${dryRun ? ' (dry-run — nothing will be written)' : ''}\n`,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const harnessIds = args.harness ? [args.harness] : Object.keys(tools.harnesses);
|
|
33
|
+
|
|
34
|
+
for (const id of harnessIds) {
|
|
35
|
+
if (!tools.harnesses[id]) {
|
|
36
|
+
console.error(`❌ Unknown harness: ${id}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let totalFiles = 0;
|
|
42
|
+
|
|
43
|
+
for (const id of harnessIds) {
|
|
44
|
+
const harness = tools.harnesses[id];
|
|
45
|
+
const render = renderers[harness.format];
|
|
46
|
+
if (!render) {
|
|
47
|
+
console.error(`❌ No renderer for format "${harness.format}" (harness: ${id})`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!harness.integrationPath) {
|
|
52
|
+
console.error(`❌ Missing "integrationPath" for harness "${id}" in tools.json`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const harnessDir = join(INTEGRATIONS_DIR, id);
|
|
57
|
+
const manifest = [];
|
|
58
|
+
|
|
59
|
+
for (const rule of rules) {
|
|
60
|
+
let output;
|
|
61
|
+
try {
|
|
62
|
+
output = render(rule, harness);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.error(`❌ ${id}: failed to render "${rule.slug}": ${err.message}`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const relPath = harness.integrationPath.replace('{{slug}}', rule.slug);
|
|
69
|
+
const outPath = join(harnessDir, relPath);
|
|
70
|
+
manifest.push({ slug: rule.slug, path: relPath, size: output.length });
|
|
71
|
+
totalFiles++;
|
|
72
|
+
|
|
73
|
+
if (!dryRun) {
|
|
74
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
75
|
+
await writeFile(outPath, output);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!dryRun) {
|
|
80
|
+
await writeFile(
|
|
81
|
+
join(harnessDir, '_manifest.json'),
|
|
82
|
+
JSON.stringify(
|
|
83
|
+
{
|
|
84
|
+
harness: id,
|
|
85
|
+
format: harness.format,
|
|
86
|
+
files: manifest.map(({ slug, path }) => ({ slug, path })),
|
|
87
|
+
},
|
|
88
|
+
null,
|
|
89
|
+
2,
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log(`✅ ${id.padEnd(12)} → ${manifest.length} file(s)`);
|
|
95
|
+
if (dryRun) {
|
|
96
|
+
for (const f of manifest) {
|
|
97
|
+
console.log(`${prefix} would write ${join(harnessDir, f.path)} (${f.size} bytes)`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const summary = dryRun
|
|
103
|
+
? `\n🔎 Dry-run: would write ${totalFiles} file(s) across ${harnessIds.length} harness(es). Nothing was written.`
|
|
104
|
+
: `\n🎉 Wrote ${totalFiles} file(s) across ${harnessIds.length} harness(es).`;
|
|
105
|
+
|
|
106
|
+
console.log(summary);
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { access, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { detectHarnesses, expandHome, pathExists } from './lib/detect.mjs';
|
|
5
|
+
|
|
6
|
+
const { values: args } = parseArgs({
|
|
7
|
+
options: {
|
|
8
|
+
scope: { type: 'string', default: 'project' },
|
|
9
|
+
harness: { type: 'string' },
|
|
10
|
+
all: { type: 'boolean', default: false },
|
|
11
|
+
'dry-run': { type: 'boolean', default: false },
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
if (!['project', 'user'].includes(args.scope)) {
|
|
16
|
+
console.error(`❌ Invalid --scope: ${args.scope} (use "project" or "user")`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const dryRun = args['dry-run'];
|
|
21
|
+
const tag = dryRun ? '[dry-run]' : ' ';
|
|
22
|
+
const log = (msg) => console.log(` ${tag} ${msg}`);
|
|
23
|
+
|
|
24
|
+
const INTEGRATIONS_DIR = process.env.INTEGRATIONS_DIR || 'integrations';
|
|
25
|
+
const tools = JSON.parse(await readFile('tools.json', 'utf8'));
|
|
26
|
+
|
|
27
|
+
let targets;
|
|
28
|
+
if (args.harness) {
|
|
29
|
+
if (!tools.harnesses[args.harness]) {
|
|
30
|
+
console.error(`❌ Unknown harness: ${args.harness}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
targets = [args.harness];
|
|
34
|
+
} else if (args.all) {
|
|
35
|
+
targets = Object.keys(tools.harnesses);
|
|
36
|
+
} else {
|
|
37
|
+
targets = await detectHarnesses(tools);
|
|
38
|
+
if (targets.length === 0) {
|
|
39
|
+
console.warn(
|
|
40
|
+
`⚠️ No harness detected. Pass --harness <id> or --all to force.${dryRun ? '' : ''}`,
|
|
41
|
+
);
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
console.log(
|
|
47
|
+
`🔍 Targets: ${targets.join(', ')} (scope: ${args.scope})${dryRun ? ' — dry-run, no changes will be made' : ''}\n`,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
let copied = 0;
|
|
51
|
+
let skippedHarnesses = 0;
|
|
52
|
+
let pluginChanges = 0;
|
|
53
|
+
|
|
54
|
+
for (const id of targets) {
|
|
55
|
+
const harness = tools.harnesses[id];
|
|
56
|
+
const srcDir = join(INTEGRATIONS_DIR, id);
|
|
57
|
+
|
|
58
|
+
if (!(await pathExists(srcDir))) {
|
|
59
|
+
console.warn(
|
|
60
|
+
`⚠️ ${id}: no compiled output at ${srcDir}, skipping. Run "mise run convert" first.`,
|
|
61
|
+
);
|
|
62
|
+
skippedHarnesses++;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const manifest = JSON.parse(await readFile(join(srcDir, '_manifest.json'), 'utf8'));
|
|
67
|
+
const destTemplate = harness.dest[args.scope];
|
|
68
|
+
if (!destTemplate) {
|
|
69
|
+
console.warn(`⚠️ ${id}: no dest for scope "${args.scope}"`);
|
|
70
|
+
skippedHarnesses++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const { slug, path: relPath } of manifest.files) {
|
|
75
|
+
const destPath = expandHome(destTemplate.replace('{{slug}}', slug));
|
|
76
|
+
if (dryRun) {
|
|
77
|
+
log(`${id}: would copy ${relPath} → ${destPath}`);
|
|
78
|
+
} else {
|
|
79
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
80
|
+
await copyFile(join(srcDir, relPath), destPath);
|
|
81
|
+
log(`${id}: ${destPath}`);
|
|
82
|
+
}
|
|
83
|
+
copied++;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (harness.plugin) {
|
|
87
|
+
const changed = await handlePlugin(id, harness.plugin, args.scope, dryRun, log);
|
|
88
|
+
if (changed) pluginChanges++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log(
|
|
93
|
+
dryRun
|
|
94
|
+
? `\n🔎 Dry-run: would copy ${copied} file(s) across ${targets.length - skippedHarnesses} harness(es).` +
|
|
95
|
+
(pluginChanges ? ` Would update ${pluginChanges} plugin config(s).` : '') +
|
|
96
|
+
` Nothing was written.`
|
|
97
|
+
: `\n🎉 Installed ${copied} file(s) across ${targets.length - skippedHarnesses} harness(es).` +
|
|
98
|
+
(pluginChanges ? ` Updated ${pluginChanges} plugin config(s).` : ''),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
if (dryRun && skippedHarnesses > 0) {
|
|
102
|
+
console.log(
|
|
103
|
+
`\n⚠️ ${skippedHarnesses} harness(es) skipped due to missing integrations. Run "mise run convert" first.`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Plugin registration ────────────────────────────────────────────────
|
|
108
|
+
async function handlePlugin(id, plugin, scope, dry, logFn) {
|
|
109
|
+
const configPath =
|
|
110
|
+
scope === 'user'
|
|
111
|
+
? expandHome('~/.config/opencode/opencode.json')
|
|
112
|
+
: join(process.cwd(), 'opencode.json');
|
|
113
|
+
|
|
114
|
+
let config = {};
|
|
115
|
+
let existed = false;
|
|
116
|
+
if (await pathExists(configPath)) {
|
|
117
|
+
existed = true;
|
|
118
|
+
try {
|
|
119
|
+
config = JSON.parse(await readFile(configPath, 'utf8'));
|
|
120
|
+
} catch {
|
|
121
|
+
console.warn(`⚠️ ${id}: ${configPath} is not valid JSON, skipping plugin registration`);
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
config.plugin = config.plugin || [];
|
|
127
|
+
if (config.plugin.includes(plugin.entry)) {
|
|
128
|
+
logFn(`${id}: plugin "${plugin.entry}" already registered in ${configPath}`);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const action = existed ? 'would add' : 'would create and add';
|
|
133
|
+
logFn(`${id}: ${action} "${plugin.entry}" to ${configPath}`);
|
|
134
|
+
|
|
135
|
+
if (!dry) {
|
|
136
|
+
config.plugin.push(plugin.entry);
|
|
137
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
138
|
+
await writeFile(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export function expandHome(p) {
|
|
6
|
+
if (p === '~') return homedir();
|
|
7
|
+
if (p.startsWith('~/')) return join(homedir(), p.slice(2));
|
|
8
|
+
return p;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function pathExists(p) {
|
|
12
|
+
try {
|
|
13
|
+
await access(expandHome(p));
|
|
14
|
+
return true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function detectHarnesses(tools) {
|
|
21
|
+
const detected = [];
|
|
22
|
+
for (const [id, harness] of Object.entries(tools.harnesses)) {
|
|
23
|
+
const dirs = harness.detect?.dirs ?? [];
|
|
24
|
+
const files = harness.detect?.files ?? [];
|
|
25
|
+
let found = false;
|
|
26
|
+
for (const p of [...dirs, ...files]) {
|
|
27
|
+
if (await pathExists(p)) { found = true; break; }
|
|
28
|
+
}
|
|
29
|
+
if (found) detected.push(id);
|
|
30
|
+
}
|
|
31
|
+
return detected;
|
|
32
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, relative, basename } from 'node:path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reads every *.md file under `dir` (recursive), parses YAML frontmatter,
|
|
7
|
+
* and returns an array of { slug, source, data, body }.
|
|
8
|
+
*
|
|
9
|
+
* `slug` is derived from the frontmatter `name` (authoritative), with the
|
|
10
|
+
* filename as fallback. Files starting with `_` are skipped.
|
|
11
|
+
*/
|
|
12
|
+
export async function readRules(dir) {
|
|
13
|
+
const entries = await readdir(dir, { recursive: true, withFileTypes: true });
|
|
14
|
+
const rules = [];
|
|
15
|
+
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
if (!entry.isFile()) continue;
|
|
18
|
+
if (!entry.name.endsWith('.md')) continue;
|
|
19
|
+
if (entry.name.startsWith('_')) continue;
|
|
20
|
+
|
|
21
|
+
const parent = entry.parentPath ?? entry.path;
|
|
22
|
+
const filePath = join(parent, entry.name);
|
|
23
|
+
const raw = await readFile(filePath, 'utf8');
|
|
24
|
+
const parsed = matter(raw);
|
|
25
|
+
const filename = basename(filePath, '.md');
|
|
26
|
+
const slug = typeof parsed.data?.name === 'string' ? parsed.data.name : filename;
|
|
27
|
+
|
|
28
|
+
rules.push({
|
|
29
|
+
slug,
|
|
30
|
+
source: relative(dir, filePath),
|
|
31
|
+
data: parsed.data ?? {},
|
|
32
|
+
body: (parsed.content ?? '').trim(),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return rules.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
37
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { stringify as yamlStringify } from 'yaml';
|
|
2
|
+
|
|
3
|
+
/** Wraps YAML frontmatter + body in a Markdown document. */
|
|
4
|
+
export function wrap(data, body) {
|
|
5
|
+
const fm = yamlStringify(data).trimEnd();
|
|
6
|
+
const content = (body ?? '').trim();
|
|
7
|
+
return `---\n${fm}\n---\n\n${content}\n`;
|
|
8
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { wrap } from './_util.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Antigravity expects a YAML frontmatter with a mandatory `trigger` field.
|
|
5
|
+
* Precedence:
|
|
6
|
+
* 1. explicit `trigger` in the rule
|
|
7
|
+
* 2. `alwaysApply: true` → always_on
|
|
8
|
+
* 3. `globs` present → glob
|
|
9
|
+
* 4. only `description` → model_decision
|
|
10
|
+
* 5. nothing → manual
|
|
11
|
+
*/
|
|
12
|
+
export function renderAntigravity(rule, _harness) {
|
|
13
|
+
const d = rule.data;
|
|
14
|
+
let trigger = d.trigger;
|
|
15
|
+
|
|
16
|
+
if (!trigger) {
|
|
17
|
+
if (d.alwaysApply === true) trigger = 'always_on';
|
|
18
|
+
else if (d.globs) trigger = 'glob';
|
|
19
|
+
else if (d.description) trigger = 'model_decision';
|
|
20
|
+
else trigger = 'manual';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const fm = { trigger };
|
|
24
|
+
if (trigger === 'glob' && d.globs) {
|
|
25
|
+
fm.globs = Array.isArray(d.globs) ? d.globs.join(',') : d.globs;
|
|
26
|
+
}
|
|
27
|
+
if (d.description) fm.description = d.description;
|
|
28
|
+
|
|
29
|
+
return wrap(fm, rule.body);
|
|
30
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { renderAntigravity } from './antigravity.mjs';
|
|
2
|
+
import { renderPiAgent } from './pi-agent.mjs';
|
|
3
|
+
import { renderOhMyPi } from './oh-my-pi.mjs';
|
|
4
|
+
import { renderOpencode } from './opencode.mjs';
|
|
5
|
+
|
|
6
|
+
/** Maps `format` from tools.json → renderer function. */
|
|
7
|
+
export const renderers = {
|
|
8
|
+
'md-yaml-trigger': renderAntigravity,
|
|
9
|
+
'md-rules-dir': renderPiAgent,
|
|
10
|
+
'md-or-mdc': renderOhMyPi,
|
|
11
|
+
'md-plugin': renderOpencode,
|
|
12
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { wrap } from './_util.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* oh-my-pi preserves `name`, `description`, optional `globs` and
|
|
5
|
+
* `alwaysApply` in the frontmatter. Accepts .md or .mdc; we emit .md.
|
|
6
|
+
*/
|
|
7
|
+
export function renderOhMyPi(rule, _harness) {
|
|
8
|
+
const d = rule.data;
|
|
9
|
+
const fm = {
|
|
10
|
+
name: d.name,
|
|
11
|
+
description: d.description,
|
|
12
|
+
};
|
|
13
|
+
if (d.globs !== undefined) fm.globs = d.globs;
|
|
14
|
+
if (d.alwaysApply !== undefined) fm.alwaysApply = d.alwaysApply;
|
|
15
|
+
return wrap(fm, rule.body);
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { wrap } from './_util.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OpenCode's `opencode-rules` plugin discovers .md files with a `description`
|
|
5
|
+
* (used for matching) and optional `globs`/`match` controls.
|
|
6
|
+
*/
|
|
7
|
+
export function renderOpencode(rule, _harness) {
|
|
8
|
+
const d = rule.data;
|
|
9
|
+
const fm = { description: d.description };
|
|
10
|
+
if (d.globs !== undefined) fm.globs = d.globs;
|
|
11
|
+
if (d.match !== undefined) fm.match = d.match;
|
|
12
|
+
return wrap(fm, rule.body);
|
|
13
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { wrap } from './_util.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* pi-agent expects each rule in its own directory as RULES.md with a
|
|
5
|
+
* frontmatter carrying `name`, `description`, and optionally `tools`.
|
|
6
|
+
*/
|
|
7
|
+
export function renderPiAgent(rule, _harness) {
|
|
8
|
+
const d = rule.data;
|
|
9
|
+
const fm = {
|
|
10
|
+
name: d.name,
|
|
11
|
+
description: d.description,
|
|
12
|
+
};
|
|
13
|
+
if (Array.isArray(d.tools) && d.tools.length > 0) {
|
|
14
|
+
fm.tools = d.tools;
|
|
15
|
+
}
|
|
16
|
+
return wrap(fm, rule.body);
|
|
17
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import Ajv from 'ajv';
|
|
4
|
+
import { readRules } from './lib/frontmatter.mjs';
|
|
5
|
+
|
|
6
|
+
const RULES_DIR = process.env.RULES_DIR || 'rules';
|
|
7
|
+
|
|
8
|
+
const schema = JSON.parse(await readFile(join(RULES_DIR, '_schema.json'), 'utf8'));
|
|
9
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
10
|
+
const validate = ajv.compile(schema);
|
|
11
|
+
|
|
12
|
+
const rules = await readRules(RULES_DIR);
|
|
13
|
+
|
|
14
|
+
if (rules.length === 0) {
|
|
15
|
+
console.error(`❌ No rules found in ${RULES_DIR}`);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
let failures = 0;
|
|
21
|
+
|
|
22
|
+
for (const rule of rules) {
|
|
23
|
+
const errors = [];
|
|
24
|
+
|
|
25
|
+
if (seen.has(rule.slug)) {
|
|
26
|
+
errors.push({ instancePath: '/name', message: `duplicate rule name "${rule.slug}"` });
|
|
27
|
+
}
|
|
28
|
+
seen.add(rule.slug);
|
|
29
|
+
|
|
30
|
+
if (!validate(rule.data)) {
|
|
31
|
+
for (const e of validate.errors ?? []) {
|
|
32
|
+
errors.push(e);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (errors.length > 0) {
|
|
37
|
+
failures++;
|
|
38
|
+
console.error(`❌ ${rule.source}`);
|
|
39
|
+
for (const e of errors) {
|
|
40
|
+
console.error(` ${e.instancePath || '/'} ${e.message}`);
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
console.log(`✅ ${rule.source}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (failures > 0) {
|
|
48
|
+
console.error(`\n💥 ${failures}/${rules.length} rule(s) failed validation.`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(`\n🎉 ${rules.length} rule(s) validated successfully.`);
|
package/tools.json
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"harnesses": {
|
|
4
|
+
"antigravity": {
|
|
5
|
+
"id": "antigravity",
|
|
6
|
+
"label": "Google Antigravity",
|
|
7
|
+
"format": "md-yaml-trigger",
|
|
8
|
+
"installKind": "per-rule",
|
|
9
|
+
"integrationPath": "{{slug}}.md",
|
|
10
|
+
"detect": { "dirs": [".agents", "~/.gemini"], "files": ["GEMINI.md", "AGENTS.md"] },
|
|
11
|
+
"dest": {
|
|
12
|
+
"project": ".agents/rules/{{slug}}.md",
|
|
13
|
+
"user": "~/.gemini/config/rules/{{slug}}.md"
|
|
14
|
+
},
|
|
15
|
+
"frontmatter": {
|
|
16
|
+
"required": ["trigger"],
|
|
17
|
+
"map": {
|
|
18
|
+
"trigger": "always_on|glob|model_decision|manual",
|
|
19
|
+
"globs": "string",
|
|
20
|
+
"description": "string"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"pi-agent": {
|
|
25
|
+
"id": "pi-agent",
|
|
26
|
+
"label": "Pi Coding Agent",
|
|
27
|
+
"format": "md-rules-dir",
|
|
28
|
+
"installKind": "per-rule",
|
|
29
|
+
"integrationPath": "{{slug}}/RULES.md",
|
|
30
|
+
"detect": { "dirs": [".pi", "~/.pi"] },
|
|
31
|
+
"dest": {
|
|
32
|
+
"project": ".pi/rules/{{slug}}/RULES.md",
|
|
33
|
+
"user": "~/.pi/agent/rules/{{slug}}/RULES.md"
|
|
34
|
+
},
|
|
35
|
+
"frontmatter": {
|
|
36
|
+
"required": ["name", "description"],
|
|
37
|
+
"map": {
|
|
38
|
+
"name": "string",
|
|
39
|
+
"description": "string",
|
|
40
|
+
"tools": "array<string>"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"oh-my-pi": {
|
|
45
|
+
"id": "oh-my-pi",
|
|
46
|
+
"label": "oh-my-pi (omp)",
|
|
47
|
+
"format": "md-or-mdc",
|
|
48
|
+
"installKind": "per-rule",
|
|
49
|
+
"integrationPath": "{{slug}}.md",
|
|
50
|
+
"detect": { "dirs": [".omp", "~/.omp"] },
|
|
51
|
+
"dest": {
|
|
52
|
+
"project": ".omp/rules/{{slug}}.md",
|
|
53
|
+
"user": "~/.omp/agent/rules/{{slug}}.md"
|
|
54
|
+
},
|
|
55
|
+
"frontmatter": {
|
|
56
|
+
"required": ["name", "description"],
|
|
57
|
+
"map": {
|
|
58
|
+
"name": "string",
|
|
59
|
+
"description": "string",
|
|
60
|
+
"globs": "array<string>|string",
|
|
61
|
+
"alwaysApply": "boolean"
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"extensions": [".md", ".mdc"]
|
|
65
|
+
},
|
|
66
|
+
"opencode": {
|
|
67
|
+
"id": "opencode",
|
|
68
|
+
"label": "OpenCode (via opencode-rules)",
|
|
69
|
+
"format": "md-plugin",
|
|
70
|
+
"installKind": "per-rule",
|
|
71
|
+
"integrationPath": "{{slug}}.md",
|
|
72
|
+
"detect": { "dirs": [".opencode", "~/.config/opencode"] },
|
|
73
|
+
"dest": {
|
|
74
|
+
"project": ".opencode/rules/{{slug}}.md",
|
|
75
|
+
"user": "~/.config/opencode/rules/{{slug}}.md"
|
|
76
|
+
},
|
|
77
|
+
"frontmatter": {
|
|
78
|
+
"required": ["description"],
|
|
79
|
+
"map": {
|
|
80
|
+
"description": "string",
|
|
81
|
+
"globs": "array<string>|string",
|
|
82
|
+
"match": "\"any\"|\"all\""
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"plugin": {
|
|
86
|
+
"package": "opencode-rules",
|
|
87
|
+
"entry": "opencode-rules@latest"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|