anycrew 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.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +266 -2
  3. package/agents/ai-engineer.md +50 -0
  4. package/agents/architect.md +45 -0
  5. package/agents/brainstormer.md +46 -0
  6. package/agents/crew-lead.md +53 -0
  7. package/agents/debugger.md +38 -0
  8. package/agents/designer.md +43 -0
  9. package/agents/doc-writer.md +45 -0
  10. package/agents/implementer.md +56 -0
  11. package/agents/product-manager.md +44 -0
  12. package/agents/release-engineer.md +45 -0
  13. package/agents/researcher.md +41 -0
  14. package/agents/reviewer.md +59 -0
  15. package/agents/scout.md +50 -0
  16. package/agents/security-reviewer.md +54 -0
  17. package/agents/spec-writer.md +43 -0
  18. package/agents/tester.md +41 -0
  19. package/bin/anycrew.mjs +6 -0
  20. package/hooks/hooks.json +18 -0
  21. package/hooks/scripts/lint-after-edit.sh +65 -0
  22. package/lib/cli.mjs +184 -0
  23. package/lib/frontmatter.mjs +80 -0
  24. package/lib/fsx.mjs +35 -0
  25. package/lib/generate.mjs +244 -0
  26. package/lib/pack.mjs +75 -0
  27. package/models.json +98 -0
  28. package/package.json +48 -4
  29. package/presets/generic.json +28 -0
  30. package/presets/go-service.json +38 -0
  31. package/presets/node-typescript-next.json +44 -0
  32. package/presets/python-fastapi-react.json +49 -0
  33. package/rules/00-sdd-process.md +21 -0
  34. package/rules/10-architecture-boundaries.md +26 -0
  35. package/rules/40-security-and-data.md +25 -0
  36. package/rules/50-llm-features.md +27 -0
  37. package/rules/60-testing.md +19 -0
  38. package/rules/70-docs-and-status.md +19 -0
  39. package/skills/crew-adr/SKILL.md +21 -0
  40. package/skills/crew-architecture/SKILL.md +24 -0
  41. package/skills/crew-brainstorm/SKILL.md +24 -0
  42. package/skills/crew-checkpoint/SKILL.md +22 -0
  43. package/skills/crew-debug/SKILL.md +22 -0
  44. package/skills/crew-deploy-check/SKILL.md +28 -0
  45. package/skills/crew-design/SKILL.md +22 -0
  46. package/skills/crew-docs/SKILL.md +23 -0
  47. package/skills/crew-implement/SKILL.md +24 -0
  48. package/skills/crew-init/SKILL.md +36 -0
  49. package/skills/crew-llm/SKILL.md +28 -0
  50. package/skills/crew-plan/SKILL.md +24 -0
  51. package/skills/crew-prd/SKILL.md +22 -0
  52. package/skills/crew-research/SKILL.md +21 -0
  53. package/skills/crew-review/SKILL.md +21 -0
  54. package/skills/crew-security/SKILL.md +21 -0
  55. package/skills/crew-spec/SKILL.md +24 -0
  56. package/skills/crew-status/SKILL.md +31 -0
  57. package/skills/crew-sync/SKILL.md +23 -0
  58. package/skills/crew-tasks/SKILL.md +21 -0
  59. package/skills/crew-test/SKILL.md +21 -0
  60. package/skills/crew-verify/SKILL.md +23 -0
  61. package/templates/AGENTS.md +85 -0
  62. package/templates/CLAUDE.md +3 -0
  63. package/templates/GEMINI.md +2 -0
  64. package/templates/anycrew.config.json +13 -0
  65. package/templates/copilot-instructions.md +6 -0
  66. package/templates/docs/00-constitution.md +62 -0
  67. package/templates/docs/01-brainstorm-and-decision.md +45 -0
  68. package/templates/docs/02-research.md +17 -0
  69. package/templates/docs/03-prd.md +31 -0
  70. package/templates/docs/04-requirements-traceability.md +9 -0
  71. package/templates/docs/05-architecture.md +35 -0
  72. package/templates/docs/06-agent-design.md +25 -0
  73. package/templates/docs/09-data-model.md +17 -0
  74. package/templates/docs/10-api-contract.md +13 -0
  75. package/templates/docs/11-frontend.md +18 -0
  76. package/templates/docs/12-design-system.md +14 -0
  77. package/templates/docs/13-conventions.md +26 -0
  78. package/templates/docs/14-security.md +23 -0
  79. package/templates/docs/15-testing.md +18 -0
  80. package/templates/docs/16-infrastructure.md +17 -0
  81. package/templates/docs/17-observability.md +8 -0
  82. package/templates/docs/19-delivery-plan.md +10 -0
  83. package/templates/docs/20-implementation-status.md +18 -0
  84. package/templates/docs/README.md +34 -0
  85. package/templates/docs/adr/ADR-template.md +13 -0
  86. package/templates/docs/adr/README.md +4 -0
  87. package/templates/docs/glossary.md +8 -0
  88. package/templates/docs/specs/NNN-template/plan.md +25 -0
  89. package/templates/docs/specs/NNN-template/spec.md +26 -0
  90. package/templates/docs/specs/NNN-template/tasks.md +14 -0
  91. package/templates/docs/specs/README.md +25 -0
package/lib/cli.mjs ADDED
@@ -0,0 +1,184 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execSync } from 'node:child_process';
4
+ import { loadPack, loadPreset, TOOLS, TIERS, modelFor } from './pack.mjs';
5
+ import { generate, scaffoldDocs } from './generate.mjs';
6
+ import { newReport, readIf, exists } from './fsx.mjs';
7
+
8
+ const CONFIG_REL = path.join('.anycrew', 'config.json');
9
+
10
+ const HELP = `anycrew — one crew of specialist agents for any coding assistant
11
+
12
+ Usage:
13
+ anycrew init [--preset <id|file>] [--tools a,b|all] [--name <project>] [--purpose "<text>"]
14
+ [--dir <path>] [--all-docs] [--force]
15
+ anycrew sync [--tools a,b|all] [--dir <path>] [--force]
16
+ anycrew doctor [--dir <path>]
17
+ anycrew list [--dir <path>]
18
+ anycrew presets
19
+ anycrew --version | --help
20
+
21
+ Tools: ${TOOLS.join(', ')} (default: the assistants detected on this machine or in the repo)
22
+ Presets: see \`anycrew presets\`.
23
+ `;
24
+
25
+ export async function main(argv) {
26
+ const { cmd, opts } = parseArgs(argv);
27
+ if (opts.version) { console.log(loadPack().version); return 0; }
28
+ if (!cmd || opts.help || cmd === 'help') { console.log(HELP); return 0; }
29
+ const projectDir = path.resolve(opts.dir ?? process.cwd());
30
+ switch (cmd) {
31
+ case 'init': return init(projectDir, opts);
32
+ case 'sync': return sync(projectDir, opts);
33
+ case 'doctor': return doctor(projectDir);
34
+ case 'list': return list(projectDir);
35
+ case 'presets': return presets();
36
+ default: console.error(`unknown command "${cmd}"\n${HELP}`); return 2;
37
+ }
38
+ }
39
+
40
+ function parseArgs(argv) {
41
+ const opts = {}; let cmd = null;
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const a = argv[i];
44
+ if (a.startsWith('--')) {
45
+ const k = a.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
46
+ const next = argv[i + 1];
47
+ if (next !== undefined && !next.startsWith('--')) { opts[k] = next; i++; } else opts[k] = true;
48
+ } else if (!cmd) cmd = a;
49
+ }
50
+ return { cmd, opts };
51
+ }
52
+
53
+ function readConfig(projectDir) {
54
+ const txt = readIf(path.join(projectDir, CONFIG_REL));
55
+ if (!txt) return null;
56
+ return JSON.parse(txt);
57
+ }
58
+
59
+ function resolveTools(opt, projectDir) {
60
+ if (opt === 'all') return [...TOOLS];
61
+ if (typeof opt === 'string') {
62
+ const list = opt.split(',').map((s) => s.trim()).filter(Boolean);
63
+ const bad = list.filter((t) => !TOOLS.includes(t));
64
+ if (bad.length) throw new Error(`unknown tool(s): ${bad.join(', ')} (known: ${TOOLS.join(', ')})`);
65
+ return list;
66
+ }
67
+ const d = detect(projectDir);
68
+ const found = TOOLS.filter((t) => d[t].cli || d[t].dir);
69
+ return found.length ? found : ['claude', 'codex', 'antigravity'];
70
+ }
71
+
72
+ function detect(projectDir) {
73
+ const has = (bin) => { try { execSync(`command -v ${bin}`, { stdio: 'ignore', shell: '/bin/sh' }); return true; } catch { return false; } };
74
+ const dir = (p) => exists(path.join(projectDir, p));
75
+ return {
76
+ claude: { cli: has('claude'), dir: dir('.claude') || dir('CLAUDE.md'), reads: 'AGENTS.md/CLAUDE.md, .claude/{agents,skills,rules}, .claude/settings.json hooks' },
77
+ codex: { cli: has('codex'), dir: dir('.codex'), reads: 'AGENTS.md, .agents/skills, .codex/agents/*.toml' },
78
+ antigravity: { cli: has('agy'), dir: dir('.agents/agents') || dir('.agents/rules'), reads: 'AGENTS.md, .agents/{rules,skills,agents}, .agents/hooks.json' },
79
+ cursor: { cli: has('cursor') || has('cursor-agent'), dir: dir('.cursor'), reads: 'AGENTS.md, .cursor/{rules,agents}, .agents/skills, .cursor/hooks.json' },
80
+ opencode: { cli: has('opencode'), dir: dir('.opencode'), reads: 'AGENTS.md, .agents/skills, .opencode/agents' },
81
+ copilot: { cli: has('copilot'), dir: dir('.github/copilot-instructions.md') || dir('.github/agents'), reads: '.github/copilot-instructions.md, .github/{instructions,agents,hooks}, .agents/skills' },
82
+ };
83
+ }
84
+
85
+ async function init(projectDir, opts) {
86
+ const pack = loadPack();
87
+ const preset = loadPreset(pack, opts.preset);
88
+ const existing = readConfig(projectDir);
89
+ const tools = resolveTools(opts.tools, projectDir);
90
+ const config = {
91
+ $schema: 'https://raw.githubusercontent.com/fadlytanjung/anycrew/main/schemas/config.schema.json',
92
+ version: 1,
93
+ anycrew: pack.version,
94
+ project: opts.name ?? existing?.project ?? path.basename(projectDir),
95
+ purpose: opts.purpose ?? existing?.purpose ?? '',
96
+ preset: preset.id,
97
+ tools,
98
+ commands: existing?.commands ?? { ...preset.commands },
99
+ models: existing?.models ?? {},
100
+ agents: existing?.agents ?? { exclude: [] },
101
+ skills: existing?.skills ?? { exclude: [] },
102
+ hooks: existing?.hooks ?? { lint_after_edit: preset.hooks?.lint_after_edit !== false },
103
+ };
104
+ fs.mkdirSync(path.join(projectDir, '.anycrew'), { recursive: true });
105
+ fs.writeFileSync(path.join(projectDir, CONFIG_REL), JSON.stringify(config, null, 2) + '\n');
106
+ const report = newReport();
107
+ report.written.push(path.join(projectDir, CONFIG_REL));
108
+ scaffoldDocs({ pack, config, preset, projectDir, report, all: !!opts.allDocs });
109
+ generate({ pack, config, preset, projectDir, tools, force: !!opts.force, report });
110
+ print(report, projectDir);
111
+ console.log(`\nInitialised ${config.project} with preset "${preset.id}" for: ${tools.join(', ')}.`);
112
+ console.log('Next: open your assistant in this folder and run /crew-status (or /crew-brainstorm for a new idea).');
113
+ return 0;
114
+ }
115
+
116
+ async function sync(projectDir, opts) {
117
+ const pack = loadPack();
118
+ const config = readConfig(projectDir);
119
+ if (!config) throw new Error(`no ${CONFIG_REL} here; run \`anycrew init\` first`);
120
+ const preset = loadPreset(pack, config.preset);
121
+ const tools = opts.tools ? resolveTools(opts.tools, projectDir) : config.tools;
122
+ const report = newReport();
123
+ generate({ pack, config, preset, projectDir, tools, force: !!opts.force, report });
124
+ print(report, projectDir);
125
+ return 0;
126
+ }
127
+
128
+ function print(report, projectDir) {
129
+ const rel = (p) => path.relative(projectDir, p) || p;
130
+ if (report.written.length) { console.log(`Written (${report.written.length}):`); for (const p of report.written) console.log(` + ${rel(p)}`); }
131
+ if (report.unchanged.length) console.log(`Unchanged: ${report.unchanged.length} file(s)`);
132
+ if (report.skipped.length) { console.log('Skipped:'); for (const s of report.skipped) console.log(` ! ${rel(s.path)} — ${s.reason}`); }
133
+ for (const n of report.notes) console.log(` · ${n}`);
134
+ }
135
+
136
+ async function doctor(projectDir) {
137
+ const pack = loadPack();
138
+ const config = readConfig(projectDir);
139
+ const d = detect(projectDir);
140
+ console.log(`anycrew ${pack.version} · project: ${projectDir}`);
141
+ console.log(config ? `config: ${CONFIG_REL} (preset ${config.preset}; tools ${config.tools.join(', ')})` : `config: none — run \`anycrew init\``);
142
+ console.log('\nAssistant CLI Files in repo Reads');
143
+ for (const t of TOOLS) {
144
+ console.log(`${t.padEnd(14)} ${(d[t].cli ? 'yes' : 'no').padEnd(6)} ${(d[t].dir ? 'yes' : 'no').padEnd(15)} ${d[t].reads}`);
145
+ }
146
+ const checks = [
147
+ ['AGENTS.md', exists(path.join(projectDir, 'AGENTS.md'))],
148
+ ['docs/00-constitution.md', exists(path.join(projectDir, 'docs', '00-constitution.md'))],
149
+ ['docs/20-implementation-status.md', exists(path.join(projectDir, 'docs', '20-implementation-status.md'))],
150
+ ['.agents/skills', exists(path.join(projectDir, '.agents', 'skills'))],
151
+ ['.anycrew/hooks/lint-after-edit.sh', exists(path.join(projectDir, '.anycrew', 'hooks', 'lint-after-edit.sh'))],
152
+ ];
153
+ console.log('\nProject files');
154
+ for (const [name, ok] of checks) console.log(` ${ok ? '✔' : '✘'} ${name}`);
155
+ if (config?.commands?.check) console.log(`\nCheck command: ${config.commands.check}`);
156
+ return 0;
157
+ }
158
+
159
+ async function list(projectDir) {
160
+ const pack = loadPack();
161
+ const config = readConfig(projectDir) ?? {};
162
+ const over = config.models ?? {};
163
+ const cell = (tool, tier) => {
164
+ const m = modelFor(pack.models, over, tool, tier);
165
+ if (m === undefined || m === null || m === '') return 'inherit';
166
+ if (typeof m === 'object') return [m.model, m.effort ?? m.model_reasoning_effort].filter(Boolean).join('/') || 'inherit';
167
+ return String(m);
168
+ };
169
+ const head = ['agent', 'tier', ...TOOLS];
170
+ const rows = pack.agents.map((a) => [a.id, a.data.tier, ...TOOLS.map((t) => cell(t, a.data.tier))]);
171
+ const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i]).length)));
172
+ const line = (r) => r.map((c, i) => String(c).padEnd(widths[i])).join(' ');
173
+ console.log(line(head)); console.log(widths.map((w) => '-'.repeat(w)).join(' '));
174
+ for (const r of rows) console.log(line(r));
175
+ console.log(`\nTiers: ${TIERS.map((t) => `${t} = ${pack.models.tiers[t].purpose}`).join('\n ')}`);
176
+ console.log(`Skills (${pack.skills.length}): ${pack.skills.map((s) => '/' + s.id).join(' ')}`);
177
+ return 0;
178
+ }
179
+
180
+ async function presets() {
181
+ const pack = loadPack();
182
+ for (const p of Object.values(pack.presets)) console.log(`${p.id.padEnd(24)} ${p.name}\n${' '.repeat(24)} ${p.description}`);
183
+ return 0;
184
+ }
@@ -0,0 +1,80 @@
1
+ // Minimal YAML front matter reader/writer. Supports scalars, quoted strings, inline arrays
2
+ // ([a, b]), block lists (- item) and one level of nested maps (key:\n sub: value). That is all
3
+ // anycrew's own files use; it is not a general YAML parser.
4
+
5
+ export function parse(text) {
6
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
7
+ if (!m) return { data: {}, body: text };
8
+ const data = {};
9
+ const lines = m[1].split(/\r?\n/);
10
+ let i = 0;
11
+ while (i < lines.length) {
12
+ const line = lines[i];
13
+ if (!line.trim() || line.trim().startsWith('#')) { i++; continue; }
14
+ const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
15
+ if (!kv) { i++; continue; }
16
+ const key = kv[1];
17
+ let raw = kv[2].trim();
18
+ if (raw === '' || raw === '|' || raw === '>-' || raw === '>' || raw === '|-') {
19
+ // block: nested map, block list, or folded/literal string
20
+ const block = [];
21
+ i++;
22
+ while (i < lines.length && (/^\s+\S/.test(lines[i]) || lines[i].trim() === '')) { block.push(lines[i]); i++; }
23
+ const nonEmpty = block.filter((l) => l.trim());
24
+ if (raw === '' && nonEmpty.every((l) => /^\s*-\s/.test(l))) {
25
+ data[key] = nonEmpty.map((l) => scalar(l.replace(/^\s*-\s*/, '')));
26
+ } else if (raw === '' && nonEmpty.every((l) => /^\s*[A-Za-z0-9_-]+:\s*/.test(l))) {
27
+ const obj = {};
28
+ for (const l of nonEmpty) { const s = /^\s*([A-Za-z0-9_-]+):\s*(.*)$/.exec(l); obj[s[1]] = scalar(s[2]); }
29
+ data[key] = obj;
30
+ } else {
31
+ const joined = nonEmpty.map((l) => l.trim());
32
+ data[key] = raw.startsWith('|') ? joined.join('\n') : joined.join(' ');
33
+ }
34
+ continue;
35
+ }
36
+ data[key] = scalar(raw);
37
+ i++;
38
+ }
39
+ return { data, body: m[2] };
40
+ }
41
+
42
+ function scalar(raw) {
43
+ raw = raw.trim();
44
+ if (/^\[.*\]$/.test(raw)) {
45
+ const inner = raw.slice(1, -1).trim();
46
+ return inner ? inner.split(',').map((s) => scalar(s)) : [];
47
+ }
48
+ if (/^"(.*)"$/.test(raw)) return raw.slice(1, -1).replace(/\\"/g, '"');
49
+ if (/^'(.*)'$/.test(raw)) return raw.slice(1, -1).replace(/''/g, "'");
50
+ if (raw === 'true') return true;
51
+ if (raw === 'false') return false;
52
+ if (raw === 'null' || raw === '~') return null;
53
+ return raw;
54
+ }
55
+
56
+ export function stringify(data, body = '') {
57
+ const out = ['---'];
58
+ for (const [k, v] of Object.entries(data)) {
59
+ if (v === undefined || v === null || v === '') continue;
60
+ if (Array.isArray(v)) {
61
+ if (v.length === 0) continue;
62
+ out.push(`${k}:`);
63
+ for (const item of v) out.push(` - ${q(item)}`);
64
+ } else if (typeof v === 'object') {
65
+ out.push(`${k}:`);
66
+ for (const [sk, sv] of Object.entries(v)) out.push(` ${sk}: ${q(sv)}`);
67
+ } else {
68
+ out.push(`${k}: ${q(v)}`);
69
+ }
70
+ }
71
+ out.push('---');
72
+ return `${out.join('\n')}\n${body.startsWith('\n') ? body.slice(1) : body}`;
73
+ }
74
+
75
+ function q(v) {
76
+ if (typeof v === 'boolean' || typeof v === 'number') return String(v);
77
+ const s = String(v);
78
+ if (/^[A-Za-z0-9_./*-][^:#"'\n]*$/.test(s) && !/\s{2,}/.test(s) && !/^(true|false|null|~)$/.test(s)) return s;
79
+ return JSON.stringify(s);
80
+ }
package/lib/fsx.mjs ADDED
@@ -0,0 +1,35 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const MARKER = 'anycrew:generated';
5
+
6
+ export function readIf(p) { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } }
7
+ export function exists(p) { try { fs.accessSync(p); return true; } catch { return false; } }
8
+ export function mkdirp(p) { fs.mkdirSync(p, { recursive: true }); }
9
+
10
+ /** Write a generated file. Refuses to overwrite a file that lacks the marker unless force. */
11
+ export function writeGenerated(p, content, report, { force = false } = {}) {
12
+ const current = readIf(p);
13
+ if (current !== null && !current.includes(MARKER) && !force) {
14
+ report.skipped.push({ path: p, reason: 'exists and is not anycrew-generated (use --force)' });
15
+ return false;
16
+ }
17
+ if (current === content) { report.unchanged.push(p); return true; }
18
+ mkdirp(path.dirname(p));
19
+ fs.writeFileSync(p, content);
20
+ report.written.push(p);
21
+ return true;
22
+ }
23
+
24
+ /** Write a file only when it does not exist yet (hand-maintained docs). */
25
+ export function writeIfMissing(p, content, report) {
26
+ if (exists(p)) { report.unchanged.push(p); return false; }
27
+ mkdirp(path.dirname(p));
28
+ fs.writeFileSync(p, content);
29
+ report.written.push(p);
30
+ return true;
31
+ }
32
+
33
+ export function newReport() { return { written: [], unchanged: [], skipped: [], notes: [] }; }
34
+
35
+ export function listDir(p) { try { return fs.readdirSync(p, { withFileTypes: true }); } catch { return []; } }
@@ -0,0 +1,244 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { stringify } from './frontmatter.mjs';
4
+ import { MARKER, writeGenerated, writeIfMissing, readIf, mkdirp, exists } from './fsx.mjs';
5
+ import { modelFor } from './pack.mjs';
6
+
7
+ const HEAD = (src) => `<!-- ${MARKER} from ${src} — edit the source and run \`npx anycrew sync\` -->\n`;
8
+ const HOOK_REL = '.anycrew/hooks/lint-after-edit.sh';
9
+
10
+ export function fill(text, vars) {
11
+ return text.replace(/\{\{([a-z_]+)\}\}/g, (_, k) => (vars[k] ?? `{{${k}}}`));
12
+ }
13
+
14
+ /** Variables shared by every template, derived from the project config and preset. */
15
+ export function templateVars(config, preset) {
16
+ const c = { ...preset.commands, ...(config.commands ?? {}) };
17
+ const l = preset.layout ?? {};
18
+ const sketch = [
19
+ `${config.project}/`,
20
+ '├── AGENTS.md agent guide (read first)',
21
+ '├── docs/ specs, ADRs, status',
22
+ l.contracts ? `├── ${l.contracts}/ machine-readable contracts` : null,
23
+ l.backend && l.backend !== '.' ? `├── ${l.backend}/` : null,
24
+ l.frontend ? `├── ${l.frontend}/` : null,
25
+ '└── .agents/ rules, skills, agents (anycrew)',
26
+ ].filter(Boolean).join('\n');
27
+ const bullets = (arr) => (arr && arr.length ? arr.map((s) => `- ${s}`).join('\n') : '');
28
+ return {
29
+ project: config.project,
30
+ purpose: config.purpose || '',
31
+ preset: preset.id,
32
+ setup_cmd: c.setup, dev_cmd: c.dev, check_cmd: c.check, test_cmd: c.test, lint_cmd: c.lint,
33
+ deploy_cmd: c.deploy ?? 'TODO: deploy command',
34
+ contracts_dir: l.contracts || 'contracts',
35
+ prompts_dir: l.backend && l.backend !== '.' ? `${l.backend}/app/prompts/` : 'prompts/',
36
+ layout_sketch: sketch,
37
+ backend_conventions: preset.backend_conventions?.length ? `### Backend\n${bullets(preset.backend_conventions)}` : '',
38
+ frontend_conventions: preset.frontend_conventions?.length ? `### Frontend\n${bullets(preset.frontend_conventions)}` : '',
39
+ date: new Date().toISOString().slice(0, 10),
40
+ };
41
+ }
42
+
43
+ /** The effective rule set: canonical rules plus preset-derived stack rules. */
44
+ export function effectiveRules(pack, preset) {
45
+ const rules = pack.rules.map((r) => ({ ...r, globs: [] }));
46
+ const pr = preset.rules ?? {};
47
+ const llm = rules.find((r) => r.id.startsWith('50-'));
48
+ if (llm) llm.globs = pr.llm_globs ?? [];
49
+ const add = (id, title, items, globs) => {
50
+ if (!items?.length || !globs?.length) return;
51
+ rules.push({
52
+ id, globs, data: { trigger: 'glob', globs: globs.join(', '), description: `${title} conventions for ${preset.name}.` },
53
+ body: `# ${title} conventions\n\n${items.map((s) => `- ${s}`).join('\n')}\n`,
54
+ });
55
+ };
56
+ add('20-backend', 'Backend', preset.backend_conventions, pr.backend_globs);
57
+ add('30-frontend', 'Frontend', preset.frontend_conventions, pr.frontend_globs);
58
+ return rules.sort((a, b) => a.id.localeCompare(b.id));
59
+ }
60
+
61
+ function selected(list, excludes = []) { return list.filter((x) => !excludes.includes(x.id)); }
62
+
63
+ export function generate({ pack, config, preset, projectDir, tools, force = false, report }) {
64
+ const vars = templateVars(config, preset);
65
+ const rules = effectiveRules(pack, preset);
66
+ const agents = selected(pack.agents, config.agents?.exclude);
67
+ const skills = selected(pack.skills, config.skills?.exclude);
68
+ const over = config.models ?? {};
69
+ const P = (...p) => path.join(projectDir, ...p);
70
+ const w = (rel, content) => writeGenerated(P(rel), content, report, { force });
71
+
72
+ // ---- Universal layer: AGENTS.md, .agents/{rules,skills,agents}, hook script ----
73
+ const agentsMd = fill(pack.templates.read('AGENTS.md'), vars);
74
+ if (!exists(P('AGENTS.md'))) writeIfMissing(P('AGENTS.md'), agentsMd, report);
75
+ else report.notes.push('AGENTS.md exists; left untouched (it is yours to edit).');
76
+
77
+ for (const r of rules) {
78
+ const fm = { trigger: r.data.trigger, description: r.data.description };
79
+ if (r.data.trigger === 'glob' || (r.globs.length && r.data.trigger === 'model_decision')) {
80
+ if (r.globs.length) { fm.trigger = 'glob'; fm.globs = r.globs.join(', '); }
81
+ }
82
+ w(`.agents/rules/${r.id}.md`, stringify(fm, HEAD('anycrew/rules') + '\n' + r.body));
83
+ }
84
+ for (const s of skills) {
85
+ w(`.agents/skills/${s.id}/SKILL.md`, stringify(s.data, HEAD(`anycrew/skills/${s.id}`) + '\n' + s.body));
86
+ copyExtras(s.dir, P('.agents', 'skills', s.id), report, force);
87
+ }
88
+ if (tools.includes('antigravity')) {
89
+ for (const a of agents) {
90
+ const fm = { name: a.data.name, description: a.data.description, model: modelFor(pack.models, over, 'antigravity', a.data.tier) ?? 'inherit' };
91
+ w(`.agents/agents/${a.id}.md`, stringify(fm, HEAD(`anycrew/agents/${a.id}`) + '\n' + a.body));
92
+ }
93
+ }
94
+ const hookOn = config.hooks?.lint_after_edit !== false;
95
+ if (hookOn) {
96
+ const hp = P(HOOK_REL);
97
+ const [shebang, ...rest] = pack.hookScript.split('\n');
98
+ const script = `${shebang}\n# ${MARKER}\n${rest.join('\n')}`;
99
+ if (writeGenerated(hp, script, report, { force: true })) fs.chmodSync(hp, 0o755);
100
+ }
101
+
102
+ // ---- Claude Code ----
103
+ if (tools.includes('claude')) {
104
+ w('CLAUDE.md', `${HEAD('anycrew/templates/CLAUDE.md')}@AGENTS.md\n`);
105
+ for (const a of agents) {
106
+ const m = modelFor(pack.models, over, 'claude', a.data.tier);
107
+ const fm = { name: a.data.name, description: a.data.description };
108
+ if (m && typeof m === 'object') { fm.model = m.model; if (m.effort) fm.effort = m.effort; } else if (m) fm.model = m;
109
+ if (a.data.tools) fm.tools = a.data.tools;
110
+ w(`.claude/agents/${a.id}.md`, stringify(fm, HEAD(`anycrew/agents/${a.id}`) + '\n' + a.body));
111
+ }
112
+ for (const s of skills) {
113
+ w(`.claude/skills/${s.id}/SKILL.md`, stringify(s.data, HEAD(`anycrew/skills/${s.id}`) + '\n' + s.body));
114
+ copyExtras(s.dir, P('.claude', 'skills', s.id), report, force);
115
+ }
116
+ for (const r of rules) {
117
+ const fm = {};
118
+ if (r.globs.length) fm.paths = r.globs;
119
+ w(`.claude/rules/${r.id}.md`, (r.globs.length ? stringify(fm, '') : '') + HEAD('anycrew/rules') + '\n' + r.body);
120
+ }
121
+ if (hookOn) mergeClaudeSettings(P('.claude', 'settings.json'), report);
122
+ }
123
+
124
+ // ---- Codex ----
125
+ if (tools.includes('codex')) {
126
+ for (const a of agents) {
127
+ const m = modelFor(pack.models, over, 'codex', a.data.tier) ?? {};
128
+ const lines = [`# ${MARKER} from anycrew/agents/${a.id} — edit the source and run \`npx anycrew sync\``,
129
+ `name = ${JSON.stringify(a.data.name)}`, `description = ${JSON.stringify(a.data.description)}`];
130
+ if (m.model) lines.push(`model = ${JSON.stringify(m.model)}`);
131
+ if (m.model_reasoning_effort) lines.push(`model_reasoning_effort = ${JSON.stringify(m.model_reasoning_effort)}`);
132
+ lines.push('', `developer_instructions = '''`, a.body.replace(/'''/g, "''\\'"), `'''`, '');
133
+ w(`.codex/agents/${a.id}.toml`, lines.join('\n'));
134
+ }
135
+ report.notes.push('Codex reads AGENTS.md and .agents/skills natively; custom agents are in .codex/agents/*.toml.');
136
+ }
137
+
138
+ // ---- Antigravity hooks ----
139
+ if (tools.includes('antigravity') && hookOn) {
140
+ const hooks = { $comment: MARKER, 'anycrew-lint': { PostToolUse: [{ matcher: 'write_to_file|replace_file_content|multi_replace_file_content', hooks: [{ type: 'command', command: `./${HOOK_REL}`, timeout: 60 }] }] } };
141
+ mergeJsonNamed(P('.agents', 'hooks.json'), 'anycrew-lint', hooks['anycrew-lint'], report);
142
+ }
143
+
144
+ // ---- Cursor ----
145
+ if (tools.includes('cursor')) {
146
+ for (const r of rules) {
147
+ const always = r.data.trigger === 'always_on';
148
+ const fm = { description: r.data.description };
149
+ if (r.globs.length) fm.globs = r.globs.join(',');
150
+ fm.alwaysApply = always;
151
+ w(`.cursor/rules/${r.id}.mdc`, stringify(fm, HEAD('anycrew/rules') + '\n' + r.body));
152
+ }
153
+ for (const a of agents) {
154
+ const fm = { name: a.data.name, description: a.data.description, model: modelFor(pack.models, over, 'cursor', a.data.tier) ?? 'inherit' };
155
+ w(`.cursor/agents/${a.id}.md`, stringify(fm, HEAD(`anycrew/agents/${a.id}`) + '\n' + a.body));
156
+ }
157
+ if (hookOn) mergeCursorHooks(P('.cursor', 'hooks.json'), report);
158
+ report.notes.push('Cursor reads .agents/skills natively; no skill copies were made under .cursor/.');
159
+ }
160
+
161
+ // ---- OpenCode ----
162
+ if (tools.includes('opencode')) {
163
+ for (const a of agents) {
164
+ const fm = { description: a.data.description, mode: 'subagent' };
165
+ const m = modelFor(pack.models, over, 'opencode', a.data.tier);
166
+ if (m) fm.model = m;
167
+ w(`.opencode/agents/${a.id}.md`, stringify(fm, HEAD(`anycrew/agents/${a.id}`) + '\n' + a.body));
168
+ }
169
+ report.notes.push('OpenCode reads AGENTS.md and .agents/skills natively; agents are in .opencode/agents/.');
170
+ }
171
+
172
+ // ---- GitHub Copilot ----
173
+ if (tools.includes('copilot')) {
174
+ const alwaysOn = rules.filter((r) => r.data.trigger === 'always_on').map((r) => r.body).join('\n');
175
+ w('.github/copilot-instructions.md', fill(pack.templates.read('copilot-instructions.md'), { always_on_rules: alwaysOn }));
176
+ for (const r of rules.filter((r) => r.globs.length)) {
177
+ w(`.github/instructions/${r.id}.instructions.md`, stringify({ applyTo: r.globs.join(',') }, HEAD('anycrew/rules') + '\n' + r.body));
178
+ }
179
+ for (const a of agents) {
180
+ const fm = { name: a.data.name, description: a.data.description };
181
+ const m = modelFor(pack.models, over, 'copilot', a.data.tier);
182
+ if (m) fm.model = m;
183
+ w(`.github/agents/${a.id}.agent.md`, stringify(fm, HEAD(`anycrew/agents/${a.id}`) + '\n' + a.body));
184
+ }
185
+ if (hookOn) {
186
+ const hooks = { $comment: MARKER, version: 1, hooks: { postToolUse: [{ type: 'command', bash: `./${HOOK_REL}`, timeoutSec: 60 }] } };
187
+ w('.github/hooks/anycrew.json', JSON.stringify(hooks, null, 2) + '\n');
188
+ }
189
+ report.notes.push('Copilot reads AGENTS.md and .agents/skills natively; agents are in .github/agents/.');
190
+ }
191
+ return report;
192
+ }
193
+
194
+ function copyExtras(srcDir, dstDir, report, force) {
195
+ for (const d of fs.readdirSync(srcDir, { withFileTypes: true })) {
196
+ if (d.name === 'SKILL.md') continue;
197
+ const s = path.join(srcDir, d.name); const t = path.join(dstDir, d.name);
198
+ if (d.isDirectory()) { mkdirp(t); copyExtras(s, t, report, force); }
199
+ else writeGenerated(t, fs.readFileSync(s, 'utf8'), report, { force: true });
200
+ }
201
+ }
202
+
203
+ function mergeClaudeSettings(file, report) {
204
+ let json = {};
205
+ const cur = readIf(file);
206
+ if (cur) { try { json = JSON.parse(cur); } catch { report.skipped.push({ path: file, reason: 'not valid JSON; add the PostToolUse hook by hand' }); return; } }
207
+ json.hooks ??= {};
208
+ json.hooks.PostToolUse ??= [];
209
+ const entry = { matcher: 'Write|Edit|MultiEdit', hooks: [{ type: 'command', command: `"$CLAUDE_PROJECT_DIR"/${HOOK_REL}`, timeout: 60, statusMessage: 'anycrew: linting edited file…' }] };
210
+ const idx = json.hooks.PostToolUse.findIndex((e) => JSON.stringify(e).includes(HOOK_REL));
211
+ if (idx >= 0) json.hooks.PostToolUse[idx] = entry; else json.hooks.PostToolUse.push(entry);
212
+ const out = JSON.stringify(json, null, 2) + '\n';
213
+ if (out !== cur) { mkdirp(path.dirname(file)); fs.writeFileSync(file, out); report.written.push(file); } else report.unchanged.push(file);
214
+ }
215
+
216
+ function mergeJsonNamed(file, key, value, report) {
217
+ let json = {};
218
+ const cur = readIf(file);
219
+ if (cur) { try { json = JSON.parse(cur); } catch { report.skipped.push({ path: file, reason: 'not valid JSON' }); return; } }
220
+ json[key] = value;
221
+ const out = JSON.stringify(json, null, 2) + '\n';
222
+ if (out !== cur) { mkdirp(path.dirname(file)); fs.writeFileSync(file, out); report.written.push(file); } else report.unchanged.push(file);
223
+ }
224
+
225
+ function mergeCursorHooks(file, report) {
226
+ let json = { version: 1, hooks: {} };
227
+ const cur = readIf(file);
228
+ if (cur) { try { json = JSON.parse(cur); } catch { report.skipped.push({ path: file, reason: 'not valid JSON' }); return; } }
229
+ json.version ??= 1; json.hooks ??= {}; json.hooks.afterFileEdit ??= [];
230
+ const entry = { command: `./${HOOK_REL}` };
231
+ if (!json.hooks.afterFileEdit.some((e) => JSON.stringify(e).includes(HOOK_REL))) json.hooks.afterFileEdit.push(entry);
232
+ const out = JSON.stringify(json, null, 2) + '\n';
233
+ if (out !== cur) { mkdirp(path.dirname(file)); fs.writeFileSync(file, out); report.written.push(file); } else report.unchanged.push(file);
234
+ }
235
+
236
+ /** Create the docs skeleton; never overwrites an existing doc. */
237
+ export function scaffoldDocs({ pack, config, preset, projectDir, report, all = false }) {
238
+ const vars = templateVars(config, preset);
239
+ const essential = new Set(['README.md', '00-constitution.md', '20-implementation-status.md', 'glossary.md', path.join('adr', 'README.md'), path.join('adr', 'ADR-template.md'), path.join('specs', 'README.md'), path.join('specs', 'NNN-template', 'spec.md'), path.join('specs', 'NNN-template', 'plan.md'), path.join('specs', 'NNN-template', 'tasks.md')]);
240
+ for (const t of pack.templates.docs) {
241
+ if (!all && !essential.has(t.rel)) continue;
242
+ writeIfMissing(path.join(projectDir, 'docs', t.rel), fill(t.text, vars), report);
243
+ }
244
+ }
package/lib/pack.mjs ADDED
@@ -0,0 +1,75 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { parse } from './frontmatter.mjs';
5
+ import { listDir, readIf } from './fsx.mjs';
6
+
7
+ export const PACK_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
8
+ export const TOOLS = ['claude', 'codex', 'antigravity', 'cursor', 'opencode', 'copilot'];
9
+ export const TIERS = ['fast', 'balanced', 'strong'];
10
+
11
+ export function loadPack() {
12
+ const pkg = JSON.parse(fs.readFileSync(path.join(PACK_ROOT, 'package.json'), 'utf8'));
13
+ const models = JSON.parse(fs.readFileSync(path.join(PACK_ROOT, 'models.json'), 'utf8'));
14
+ const agents = listDir(path.join(PACK_ROOT, 'agents'))
15
+ .filter((d) => d.isFile() && d.name.endsWith('.md'))
16
+ .map((d) => {
17
+ const file = path.join(PACK_ROOT, 'agents', d.name);
18
+ const { data, body } = parse(fs.readFileSync(file, 'utf8'));
19
+ return { id: d.name.replace(/\.md$/, ''), file, data, body: body.trim() + '\n' };
20
+ })
21
+ .sort((a, b) => a.id.localeCompare(b.id));
22
+ const skills = listDir(path.join(PACK_ROOT, 'skills'))
23
+ .filter((d) => d.isDirectory())
24
+ .map((d) => {
25
+ const dir = path.join(PACK_ROOT, 'skills', d.name);
26
+ const text = readIf(path.join(dir, 'SKILL.md')) ?? '';
27
+ const { data, body } = parse(text);
28
+ return { id: d.name, dir, data, body: body.trim() + '\n', raw: text };
29
+ })
30
+ .sort((a, b) => a.id.localeCompare(b.id));
31
+ const rules = listDir(path.join(PACK_ROOT, 'rules'))
32
+ .filter((d) => d.isFile() && d.name.endsWith('.md'))
33
+ .map((d) => {
34
+ const file = path.join(PACK_ROOT, 'rules', d.name);
35
+ const { data, body } = parse(fs.readFileSync(file, 'utf8'));
36
+ return { id: d.name.replace(/\.md$/, ''), file, data, body: body.trim() + '\n' };
37
+ })
38
+ .sort((a, b) => a.id.localeCompare(b.id));
39
+ const presets = Object.fromEntries(
40
+ listDir(path.join(PACK_ROOT, 'presets'))
41
+ .filter((d) => d.isFile() && d.name.endsWith('.json'))
42
+ .map((d) => { const p = JSON.parse(fs.readFileSync(path.join(PACK_ROOT, 'presets', d.name), 'utf8')); return [p.id, p]; }),
43
+ );
44
+ const templates = {
45
+ read: (rel) => fs.readFileSync(path.join(PACK_ROOT, 'templates', rel), 'utf8'),
46
+ docs: walk(path.join(PACK_ROOT, 'templates', 'docs')),
47
+ };
48
+ const hookScript = fs.readFileSync(path.join(PACK_ROOT, 'hooks', 'scripts', 'lint-after-edit.sh'), 'utf8');
49
+ return { version: pkg.version, models, agents, skills, rules, presets, templates, hookScript };
50
+ }
51
+
52
+ function walk(root, rel = '') {
53
+ const out = [];
54
+ for (const d of listDir(path.join(root, rel))) {
55
+ const r = path.join(rel, d.name);
56
+ if (d.isDirectory()) out.push(...walk(root, r));
57
+ else out.push({ rel: r, text: fs.readFileSync(path.join(root, r), 'utf8') });
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /** Resolve the model setting for a tool and tier, honouring project overrides. */
63
+ export function modelFor(models, overrides, tool, tier) {
64
+ const base = models.tools?.[tool]?.[tier];
65
+ const over = overrides?.[tool]?.[tier];
66
+ if (over !== undefined) return over;
67
+ return base;
68
+ }
69
+
70
+ export function loadPreset(pack, idOrPath) {
71
+ if (!idOrPath) return pack.presets.generic;
72
+ if (pack.presets[idOrPath]) return pack.presets[idOrPath];
73
+ if (fs.existsSync(idOrPath)) return JSON.parse(fs.readFileSync(idOrPath, 'utf8'));
74
+ throw new Error(`unknown preset "${idOrPath}" (available: ${Object.keys(pack.presets).join(', ')})`);
75
+ }