agency-rules 0.0.0-stage → 0.3.2

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/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "agency-rules",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.2",
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": [
17
+ "ai",
18
+ "agents",
19
+ "rules",
20
+ "antigravity",
21
+ "pi-agent",
22
+ "opencode",
23
+ "oh-my-pi"
24
+ ],
25
+ "engines": {
26
+ "node": ">=20.12"
27
+ },
28
+ "files": [
29
+ "scripts/",
30
+ "tools.json",
31
+ "rules/_schema.json",
32
+ "docs/",
33
+ "README.md",
34
+ "LICENSE",
35
+ "CHANGELOG.md"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "provenance": true
40
+ },
41
+ "devDependencies": {
42
+ "ajv": "^8.17.1",
43
+ "gray-matter": "^4.0.3",
44
+ "yaml": "^2.5.0"
45
+ }
46
+ }
@@ -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 { mkdir, readFile, 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,187 @@
1
+ import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { stdin as input, stdout as output } from 'node:process';
4
+ import { createInterface } from 'node:readline/promises';
5
+ import { parseArgs } from 'node:util';
6
+ import { detectHarnesses, expandHome, pathExists } from './lib/detect.mjs';
7
+
8
+ const { values: args } = parseArgs({
9
+ options: {
10
+ scope: { type: 'string' },
11
+ harness: { type: 'string' },
12
+ all: { type: 'boolean', default: false },
13
+ 'dry-run': { type: 'boolean', default: false },
14
+ },
15
+ });
16
+
17
+ const dryRun = args['dry-run'];
18
+ const tag = dryRun ? '[dry-run]' : ' ';
19
+ const log = (msg) => console.log(` ${tag} ${msg}`);
20
+
21
+ const INTEGRATIONS_DIR = process.env.INTEGRATIONS_DIR || 'integrations';
22
+ const tools = JSON.parse(await readFile('tools.json', 'utf8'));
23
+
24
+ let finalScope = args.scope;
25
+ let finalHarness = args.harness;
26
+
27
+ // ── Interactive Mode (si no se pasan parámetros y es una terminal) ─────
28
+ if (input.isTTY && ((!finalHarness && !args.all) || !finalScope)) {
29
+ const rl = createInterface({ input, output });
30
+
31
+ if (!finalHarness && !args.all) {
32
+ console.log('\n🎯 Select a harness to install into:');
33
+ const keys = Object.keys(tools.harnesses);
34
+ keys.forEach((k, i) => {
35
+ console.log(` ${i + 1}) ${k} (${tools.harnesses[k].label})`);
36
+ });
37
+ console.log(` ${keys.length + 1}) Detect automatically`);
38
+ console.log(` ${keys.length + 2}) All harnesses`);
39
+
40
+ const answer = await rl.question(`\nChoice [1-${keys.length + 2}] (default: detect): `);
41
+ const num = parseInt(answer.trim(), 10);
42
+
43
+ if (!Number.isNaN(num) && num >= 1 && num <= keys.length) {
44
+ finalHarness = keys[num - 1];
45
+ } else if (num === keys.length + 2 || answer.trim().toLowerCase() === 'all') {
46
+ args.all = true;
47
+ }
48
+ }
49
+
50
+ if (!finalScope) {
51
+ console.log('\n🌍 Select installation scope:');
52
+ console.log(' 1) project (local to this repo)');
53
+ console.log(' 2) user (global for your user)');
54
+
55
+ const answer = await rl.question('\nChoice [1-2] (default: 1): ');
56
+ finalScope =
57
+ answer.trim() === '2' || answer.trim().toLowerCase() === 'user' ? 'user' : 'project';
58
+ }
59
+
60
+ rl.close();
61
+ console.log('');
62
+ }
63
+
64
+ // Fallback si no se seleccionó nada
65
+ if (!finalScope) finalScope = 'project';
66
+
67
+ if (!['project', 'user'].includes(finalScope)) {
68
+ console.error(`❌ Invalid --scope: ${finalScope} (use "project" or "user")`);
69
+ process.exit(1);
70
+ }
71
+
72
+ let targets;
73
+ if (finalHarness) {
74
+ if (!tools.harnesses[finalHarness]) {
75
+ console.error(`❌ Unknown harness: ${finalHarness}`);
76
+ process.exit(1);
77
+ }
78
+ targets = [finalHarness];
79
+ } else if (args.all) {
80
+ targets = Object.keys(tools.harnesses);
81
+ } else {
82
+ targets = await detectHarnesses(tools);
83
+ if (targets.length === 0) {
84
+ console.warn('⚠️ No harness detected. Pass --harness <id> or --all to force.');
85
+ process.exit(0);
86
+ }
87
+ }
88
+
89
+ console.log(
90
+ `🔍 Targets: ${targets.join(', ')} (scope: ${finalScope})${
91
+ dryRun ? ' — dry-run, no changes will be made' : ''
92
+ }\n`,
93
+ );
94
+
95
+ let copied = 0;
96
+ let skippedHarnesses = 0;
97
+ let pluginChanges = 0;
98
+
99
+ for (const id of targets) {
100
+ const harness = tools.harnesses[id];
101
+ const srcDir = join(INTEGRATIONS_DIR, id);
102
+
103
+ if (!(await pathExists(srcDir))) {
104
+ console.warn(
105
+ `⚠️ ${id}: no compiled output at ${srcDir}, skipping. Run "mise run convert" first.`,
106
+ );
107
+ skippedHarnesses++;
108
+ continue;
109
+ }
110
+
111
+ const manifest = JSON.parse(await readFile(join(srcDir, '_manifest.json'), 'utf8'));
112
+ const destTemplate = harness.dest[finalScope];
113
+ if (!destTemplate) {
114
+ console.warn(`⚠️ ${id}: no dest for scope "${finalScope}"`);
115
+ skippedHarnesses++;
116
+ continue;
117
+ }
118
+
119
+ for (const { slug, path: relPath } of manifest.files) {
120
+ const destPath = expandHome(destTemplate.replace('{{slug}}', slug));
121
+ if (dryRun) {
122
+ log(`${id}: would copy ${relPath} → ${destPath}`);
123
+ } else {
124
+ await mkdir(dirname(destPath), { recursive: true });
125
+ await copyFile(join(srcDir, relPath), destPath);
126
+ log(`${id}: ${destPath}`);
127
+ }
128
+ copied++;
129
+ }
130
+
131
+ if (harness.plugin) {
132
+ const changed = await handlePlugin(id, harness.plugin, finalScope, dryRun, log);
133
+ if (changed) pluginChanges++;
134
+ }
135
+ }
136
+
137
+ console.log(
138
+ dryRun
139
+ ? `\n🔎 Dry-run: would copy ${copied} file(s) across ${targets.length - skippedHarnesses} harness(es).` +
140
+ (pluginChanges ? ` Would update ${pluginChanges} plugin config(s).` : '') +
141
+ ' Nothing was written.'
142
+ : `\n🎉 Installed ${copied} file(s) across ${targets.length - skippedHarnesses} harness(es).` +
143
+ (pluginChanges ? ` Updated ${pluginChanges} plugin config(s).` : ''),
144
+ );
145
+
146
+ if (dryRun && skippedHarnesses > 0) {
147
+ console.log(
148
+ `\n⚠️ ${skippedHarnesses} harness(es) skipped due to missing integrations. Run "mise run convert" first.`,
149
+ );
150
+ }
151
+
152
+ // ── Plugin registration ──────────────────────────────────────────────
153
+ async function handlePlugin(id, plugin, scope, dry, logFn) {
154
+ const configPath =
155
+ scope === 'user'
156
+ ? expandHome('~/.config/opencode/opencode.json')
157
+ : join(process.cwd(), 'opencode.json');
158
+
159
+ let config = {};
160
+ let existed = false;
161
+ if (await pathExists(configPath)) {
162
+ existed = true;
163
+ try {
164
+ config = JSON.parse(await readFile(configPath, 'utf8'));
165
+ } catch {
166
+ console.warn(`⚠️ ${id}: ${configPath} is not valid JSON, skipping plugin registration`);
167
+ return false;
168
+ }
169
+ }
170
+
171
+ config.plugin = config.plugin || [];
172
+ if (config.plugin.includes(plugin.entry)) {
173
+ logFn(`${id}: plugin "${plugin.entry}" already registered in ${configPath}`);
174
+ return false;
175
+ }
176
+
177
+ const action = existed ? 'would add' : 'would create and add';
178
+ logFn(`${id}: ${action} "${plugin.entry}" to ${configPath}`);
179
+
180
+ if (!dry) {
181
+ config.plugin.push(plugin.entry);
182
+ await mkdir(dirname(configPath), { recursive: true });
183
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
184
+ }
185
+
186
+ return true;
187
+ }
@@ -0,0 +1,35 @@
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)) {
28
+ found = true;
29
+ break;
30
+ }
31
+ }
32
+ if (found) detected.push(id);
33
+ }
34
+ return detected;
35
+ }
@@ -0,0 +1,37 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { basename, join, relative } 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 { renderOhMyPi } from './oh-my-pi.mjs';
3
+ import { renderOpencode } from './opencode.mjs';
4
+ import { renderPiAgent } from './pi-agent.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.`);