turntax 0.0.0-stage → 0.2.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.
@@ -0,0 +1,551 @@
1
+ const str = (v) => v === undefined ? '' : Array.isArray(v) ? v.join(', ') : String(v);
2
+ const has = (fm, key) => str(fm[key]).trim() !== '';
3
+ export const TOOLS = [
4
+ {
5
+ id: 'claude-code',
6
+ name: 'Claude Code',
7
+ families: ['claude'],
8
+ sources: [
9
+ 'https://code.claude.com/docs/en/memory',
10
+ 'https://code.claude.com/docs/en/skills',
11
+ 'https://code.claude.com/docs/en/sub-agents',
12
+ ],
13
+ checkedAt: '2026-10-10',
14
+ rules: [
15
+ {
16
+ kind: 'hierarchical',
17
+ names: ['CLAUDE.md', 'CLAUDE.local.md'],
18
+ sameLevelDirs: ['.claude'],
19
+ nested: 'on-demand',
20
+ nestedTrigger: 'when Claude reads or edits a file in',
21
+ stripHtmlComments: true,
22
+ importDepth: 4,
23
+ },
24
+ {
25
+ kind: 'hierarchical',
26
+ names: ['AGENTS.md'],
27
+ sameLevelDirs: ['.claude'],
28
+ nested: 'on-demand',
29
+ nestedTrigger: 'when Claude reads a file in',
30
+ importDepth: 4,
31
+ condition: 'claude-reads-agents-md',
32
+ note: 'Read only because no CLAUDE.md or CLAUDE.local.md exists at the root (default setting).',
33
+ },
34
+ {
35
+ kind: 'frontmatter',
36
+ pattern: /^(?:.*\/)?\.claude\/rules\/.+\.md$/,
37
+ rootPrefixes: ['.claude/rules/'],
38
+ decide: (fm) => has(fm, 'paths')
39
+ ? { mode: 'on-demand', trigger: `paths: ${str(fm.paths)}` }
40
+ : { mode: 'always', trigger: 'rule without paths' },
41
+ },
42
+ {
43
+ kind: 'skill',
44
+ pattern: /^(?:.*\/)?\.claude\/skills\/[^/]+\/SKILL\.md$/,
45
+ rootPrefix: '.claude/skills/',
46
+ descriptionCap: 1536,
47
+ hideFlag: 'disable-model-invocation',
48
+ bodyTrigger: 'when the skill is invoked',
49
+ },
50
+ {
51
+ kind: 'skill',
52
+ pattern: /^(?:.*\/)?\.claude\/commands\/.+\.md$/,
53
+ rootPrefix: '.claude/commands/',
54
+ descriptionCap: 1536,
55
+ hideFlag: 'disable-model-invocation',
56
+ bodyTrigger: 'when the command is invoked',
57
+ },
58
+ {
59
+ kind: 'skill',
60
+ pattern: /^(?:.*\/)?\.claude\/agents\/[^/]+\.md$/,
61
+ rootPrefix: '.claude/agents/',
62
+ bodyTrigger: "as the subagent's own system prompt when it runs",
63
+ },
64
+ ],
65
+ limits: [
66
+ {
67
+ pattern: /(^|\/)CLAUDE(\.local)?\.md$/,
68
+ unit: 'lines',
69
+ max: 200,
70
+ severity: 'advice',
71
+ text: 'Claude Code docs recommend keeping each CLAUDE.md under 200 lines.',
72
+ source: 'https://code.claude.com/docs/en/memory',
73
+ },
74
+ ],
75
+ notes: [
76
+ 'User (~/.claude/CLAUDE.md), managed policy and auto-memory files live outside the repository and are not counted.',
77
+ 'With the "claude-md-and-agents-md" setting Claude Code reads AGENTS.md as well; the default reads it only when no CLAUDE.md exists.',
78
+ ],
79
+ },
80
+ {
81
+ id: 'codex',
82
+ name: 'OpenAI Codex',
83
+ families: ['openai'],
84
+ sources: [
85
+ 'https://learn.chatgpt.com/docs/agent-configuration/agents-md',
86
+ 'https://learn.chatgpt.com/docs/build-skills',
87
+ ],
88
+ checkedAt: '2026-10-10',
89
+ rules: [
90
+ {
91
+ kind: 'hierarchical',
92
+ names: ['AGENTS.override.md', 'AGENTS.md'],
93
+ onePerDirectory: true,
94
+ nested: 'on-demand',
95
+ nestedTrigger: 'only when Codex is launched in',
96
+ maxBytes: 32 * 1024,
97
+ },
98
+ {
99
+ kind: 'skill',
100
+ pattern: /^(?:.*\/)?\.agents\/skills\/[^/]+\/SKILL\.md$/,
101
+ rootPrefix: '.agents/skills/',
102
+ bodyTrigger: 'when the skill is selected',
103
+ },
104
+ ],
105
+ limits: [
106
+ {
107
+ pattern: /(^|\/)AGENTS(\.override)?\.md$/,
108
+ unit: 'bytes',
109
+ max: 32 * 1024,
110
+ severity: 'truncate',
111
+ text: 'Codex stops reading project docs at project_doc_max_bytes (default 32 KiB); the rest is dropped.',
112
+ source: 'https://learn.chatgpt.com/docs/agent-configuration/agents-md',
113
+ },
114
+ ],
115
+ notes: ['Global ~/.codex/AGENTS.md is outside the repository and not counted.'],
116
+ },
117
+ {
118
+ id: 'gemini-cli',
119
+ name: 'Gemini CLI',
120
+ families: ['gemini'],
121
+ sources: ['https://geminicli.com/docs/cli/gemini-md/'],
122
+ checkedAt: '2026-10-10',
123
+ rules: [
124
+ {
125
+ kind: 'hierarchical',
126
+ names: ['GEMINI.md'],
127
+ nested: 'on-demand',
128
+ nestedTrigger: 'when a tool accesses',
129
+ // The docs describe @file.md imports but not a depth limit; deeper chains are rare.
130
+ importDepth: 5,
131
+ },
132
+ ],
133
+ limits: [],
134
+ notes: [
135
+ 'Only GEMINI.md is read by default; context.fileName can add AGENTS.md (not modelled).',
136
+ 'Global ~/.gemini/GEMINI.md is outside the repository and not counted.',
137
+ ],
138
+ },
139
+ {
140
+ id: 'cursor',
141
+ name: 'Cursor',
142
+ families: [],
143
+ sources: ['https://cursor.com/docs/context/rules', 'https://cursor.com/help/customization/rules'],
144
+ checkedAt: '2026-10-10',
145
+ rules: [
146
+ {
147
+ kind: 'frontmatter',
148
+ pattern: /^(?:.*\/)?\.cursor\/rules\/.+\.mdc$/,
149
+ rootPrefixes: ['.cursor/rules/'],
150
+ decide: (fm) => {
151
+ if (fm.alwaysApply === true || str(fm.alwaysApply) === 'true')
152
+ return { mode: 'always', trigger: 'alwaysApply: true' };
153
+ if (has(fm, 'globs'))
154
+ return { mode: 'on-demand', trigger: `globs: ${str(fm.globs)}` };
155
+ if (has(fm, 'description'))
156
+ return {
157
+ mode: 'on-demand',
158
+ trigger: 'agent decides from description',
159
+ descriptionOnly: true,
160
+ note: 'Description assumed to be in context so the agent can decide (inferred, not documented).',
161
+ };
162
+ return { mode: 'manual', trigger: '@-mention only' };
163
+ },
164
+ },
165
+ {
166
+ kind: 'ignored',
167
+ pattern: /^(?:.*\/)?\.cursor\/rules\/.+\.md$/,
168
+ reason: 'Cursor ignores plain .md files in .cursor/rules; rename to .mdc.',
169
+ },
170
+ { kind: 'root-file', path: '.cursorrules', note: 'Legacy file; Cursor plans to deprecate it.' },
171
+ {
172
+ kind: 'hierarchical',
173
+ names: ['AGENTS.md'],
174
+ nested: 'on-demand',
175
+ nestedTrigger: 'when working in',
176
+ },
177
+ {
178
+ kind: 'hierarchical',
179
+ names: ['CLAUDE.md'],
180
+ nested: 'on-demand',
181
+ nestedTrigger: 'when working in',
182
+ },
183
+ ],
184
+ limits: [
185
+ {
186
+ pattern: /\.mdc$/,
187
+ unit: 'lines',
188
+ max: 500,
189
+ severity: 'advice',
190
+ text: 'Cursor docs recommend keeping each rule under 500 lines.',
191
+ source: 'https://cursor.com/docs/context/rules',
192
+ },
193
+ ],
194
+ notes: ['User and Team rules are configured in Cursor settings, not in the repository, and are not counted.'],
195
+ },
196
+ {
197
+ id: 'copilot',
198
+ name: 'GitHub Copilot',
199
+ families: [],
200
+ sources: [
201
+ 'https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions',
202
+ 'https://code.visualstudio.com/docs/copilot/customization/custom-instructions',
203
+ ],
204
+ checkedAt: '2026-10-10',
205
+ rules: [
206
+ { kind: 'root-file', path: '.github/copilot-instructions.md' },
207
+ {
208
+ kind: 'frontmatter',
209
+ pattern: /^\.github\/instructions\/.+\.instructions\.md$/,
210
+ rootPrefixes: ['.github/instructions/'],
211
+ decide: (fm) => {
212
+ if (has(fm, 'applyTo'))
213
+ return { mode: 'on-demand', trigger: `applyTo: ${str(fm.applyTo)}` };
214
+ if (has(fm, 'description'))
215
+ return {
216
+ mode: 'on-demand',
217
+ trigger: 'agent decides from description (VS Code)',
218
+ descriptionOnly: true,
219
+ note: 'Description assumed to be in context so the agent can decide (inferred, not documented).',
220
+ };
221
+ return { mode: 'manual', trigger: 'attached manually' };
222
+ },
223
+ },
224
+ {
225
+ kind: 'hierarchical',
226
+ names: ['AGENTS.md'],
227
+ nested: 'on-demand',
228
+ nestedTrigger: 'nearest AGENTS.md wins when working in',
229
+ },
230
+ {
231
+ kind: 'hierarchical',
232
+ names: ['CLAUDE.md', 'GEMINI.md'],
233
+ onePerDirectory: true,
234
+ nested: 'ignored',
235
+ nestedTrigger: '',
236
+ condition: 'copilot-no-agents-md',
237
+ note: 'Copilot docs allow a single root CLAUDE.md or GEMINI.md as an alternative to AGENTS.md. When both exist the docs do not say which wins; CLAUDE.md is assumed.',
238
+ },
239
+ ],
240
+ limits: [],
241
+ notes: ['Support for AGENTS.md and CLAUDE.md differs by Copilot surface and VS Code settings.'],
242
+ },
243
+ {
244
+ id: 'windsurf',
245
+ name: 'Windsurf (Devin Desktop)',
246
+ families: [],
247
+ sources: ['https://docs.devin.ai/desktop/cascade/memories'],
248
+ checkedAt: '2026-10-10',
249
+ rules: [
250
+ {
251
+ kind: 'frontmatter',
252
+ pattern: /^(?:.*\/)?\.(?:devin|windsurf)\/rules\/.+\.md$/,
253
+ rootPrefixes: ['.devin/rules/', '.windsurf/rules/'],
254
+ decide: (fm) => {
255
+ switch (str(fm.trigger).trim()) {
256
+ case 'always_on':
257
+ return { mode: 'always', trigger: 'trigger: always_on' };
258
+ case 'model_decision':
259
+ return { mode: 'on-demand', trigger: 'trigger: model_decision', descriptionOnly: true };
260
+ case 'glob':
261
+ return { mode: 'on-demand', trigger: `trigger: glob ${str(fm.globs)}`.trim() };
262
+ case 'manual':
263
+ return { mode: 'manual', trigger: 'trigger: manual (@rule-name)' };
264
+ default:
265
+ return {
266
+ mode: 'manual',
267
+ trigger: 'no trigger set',
268
+ note: 'Docs do not state the default when trigger is missing; not counted.',
269
+ problem: 'Rule has no trigger frontmatter; set always_on, model_decision, glob or manual.',
270
+ };
271
+ }
272
+ },
273
+ },
274
+ { kind: 'root-file', path: '.windsurfrules', note: 'Legacy single-file rules, still read.' },
275
+ {
276
+ kind: 'hierarchical',
277
+ names: ['AGENTS.md'],
278
+ nested: 'on-demand',
279
+ nestedTrigger: 'auto-glob for files in',
280
+ },
281
+ ],
282
+ limits: [
283
+ {
284
+ pattern: /(^|\/)(\.(devin|windsurf)\/rules\/.+\.md|\.windsurfrules)$/,
285
+ unit: 'chars',
286
+ max: 12000,
287
+ severity: 'limit',
288
+ text: 'Windsurf limits workspace rule files to 12,000 characters each.',
289
+ source: 'https://docs.devin.ai/desktop/cascade/memories',
290
+ },
291
+ ],
292
+ notes: ['Global rules (~/.codeium/windsurf/memories/global_rules.md) are outside the repository and not counted.'],
293
+ },
294
+ {
295
+ id: 'cline',
296
+ name: 'Cline',
297
+ families: [],
298
+ sources: ['https://docs.cline.bot/features/cline-rules'],
299
+ checkedAt: '2026-10-10',
300
+ rules: [
301
+ {
302
+ kind: 'frontmatter',
303
+ pattern: /^\.(?:clinerules|cline\/rules)\/.+\.md$/,
304
+ rootPrefixes: ['.clinerules/', '.cline/rules/'],
305
+ decide: (fm) => {
306
+ if (Array.isArray(fm.paths) && fm.paths.length === 0)
307
+ return { mode: 'ignored', trigger: 'paths: [] (never applies)' };
308
+ if (has(fm, 'paths'))
309
+ return { mode: 'on-demand', trigger: `paths: ${str(fm.paths)}` };
310
+ return { mode: 'always', trigger: 'rule without paths' };
311
+ },
312
+ },
313
+ ],
314
+ limits: [],
315
+ notes: [
316
+ 'Each rule can be toggled off in Cline; the audit assumes rules are on.',
317
+ 'Cline also detects AGENTS.md, .cursorrules and .windsurfrules; whether they are on by default is not documented, so they are not counted.',
318
+ 'A single .clinerules file (instead of a folder) is not documented and is not counted.',
319
+ ],
320
+ },
321
+ {
322
+ id: 'roo-code',
323
+ name: 'Roo Code',
324
+ families: [],
325
+ sources: ['https://roocodeinc.github.io/Roo-Code/features/custom-instructions'],
326
+ checkedAt: '2026-10-10',
327
+ rules: [
328
+ {
329
+ kind: 'precedence',
330
+ groups: [
331
+ { label: '.roo/rules/', patterns: [/^\.roo\/rules\/.+/] },
332
+ { label: '.roorules', patterns: [/^\.roorules$/] },
333
+ { label: '.clinerules', patterns: [/^\.clinerules$/] },
334
+ ],
335
+ note: 'Legacy .roorules and .clinerules are read only when .roo/rules/ has no content; their order between each other is assumed.',
336
+ },
337
+ { kind: 'pattern', pattern: /^\.roo\/rules-[\w-]+\/.+/, mode: 'on-demand', trigger: 'only in that Roo mode' },
338
+ { kind: 'pattern', pattern: /^\.roorules-[\w-]+$/, mode: 'on-demand', trigger: 'only in that Roo mode (legacy)' },
339
+ {
340
+ kind: 'hierarchical',
341
+ names: ['AGENTS.md', 'AGENT.md'],
342
+ onePerDirectory: true,
343
+ nested: 'skip',
344
+ nestedTrigger: '',
345
+ note: 'Read by default; the roo-cline.useAgentRules setting turns it off.',
346
+ },
347
+ ],
348
+ limits: [],
349
+ notes: ['Global ~/.roo/rules/ is outside the repository and not counted.'],
350
+ },
351
+ {
352
+ id: 'kiro',
353
+ name: 'Kiro',
354
+ families: [],
355
+ sources: ['https://kiro.dev/docs/steering/'],
356
+ checkedAt: '2026-10-10',
357
+ rules: [
358
+ {
359
+ kind: 'frontmatter',
360
+ pattern: /^\.kiro\/steering\/[^/]+\.md$/,
361
+ rootPrefixes: ['.kiro/steering/'],
362
+ decide: (fm) => {
363
+ switch (str(fm.inclusion).trim()) {
364
+ case '':
365
+ case 'always':
366
+ return { mode: 'always', trigger: 'inclusion: always' };
367
+ case 'fileMatch':
368
+ return { mode: 'on-demand', trigger: `fileMatch: ${str(fm.fileMatchPattern)}` };
369
+ case 'manual':
370
+ return { mode: 'manual', trigger: 'inclusion: manual (#name)' };
371
+ case 'auto':
372
+ return {
373
+ mode: 'on-demand',
374
+ trigger: 'inclusion: auto (matched on description)',
375
+ descriptionOnly: true,
376
+ note: 'Description assumed to be in context so Kiro can match it (inferred, not documented).',
377
+ };
378
+ default:
379
+ return { mode: 'always', trigger: `inclusion: ${str(fm.inclusion)} (unknown, treated as always)` };
380
+ }
381
+ },
382
+ },
383
+ { kind: 'hierarchical', names: ['AGENTS.md'], nested: 'skip', nestedTrigger: '' },
384
+ ],
385
+ limits: [],
386
+ notes: [
387
+ 'fileMatch and manual modes need Kiro CLI V3; older versions skip those files.',
388
+ 'Global ~/.kiro/steering/ is outside the repository and not counted.',
389
+ ],
390
+ },
391
+ {
392
+ id: 'junie',
393
+ name: 'JetBrains Junie',
394
+ families: [],
395
+ sources: ['https://junie.jetbrains.com/docs/guidelines-and-memory.html'],
396
+ checkedAt: '2026-10-10',
397
+ rules: [
398
+ {
399
+ kind: 'precedence',
400
+ groups: [
401
+ { label: '.junie/AGENTS.md', patterns: [/^\.junie\/AGENTS\.md$/] },
402
+ { label: 'AGENTS.md', patterns: [/^AGENTS\.md$/, /^\.junie\/playbook\.md$/, /^\.junie\/rules\/[^/]+\.md$/] },
403
+ { label: '.junie/guidelines', patterns: [/^\.junie\/guidelines\.md$/, /^\.junie\/guidelines\/[^/]+\.md$/] },
404
+ ],
405
+ note: 'Follows the IDE plugin order; a custom guidelines path set in project settings is not modelled.',
406
+ },
407
+ ],
408
+ limits: [],
409
+ notes: ['Global ~/.junie/AGENTS.md is outside the repository and not counted.'],
410
+ },
411
+ {
412
+ id: 'zed',
413
+ name: 'Zed',
414
+ families: [],
415
+ sources: ['https://zed.dev/docs/ai/instructions'],
416
+ checkedAt: '2026-10-10',
417
+ rules: [
418
+ {
419
+ kind: 'precedence',
420
+ groups: [
421
+ '.rules',
422
+ '.cursorrules',
423
+ '.windsurfrules',
424
+ '.clinerules',
425
+ '.github/copilot-instructions.md',
426
+ 'AGENT.md',
427
+ 'AGENTS.md',
428
+ 'CLAUDE.md',
429
+ 'GEMINI.md',
430
+ ].map((path) => ({ label: path, patterns: [new RegExp(`^${path.replace(/\./g, '\\.')}$`)] })),
431
+ note: 'Zed uses only the first of these files it finds at the project root.',
432
+ },
433
+ ],
434
+ limits: [],
435
+ notes: ['Personal ~/.config/zed/AGENTS.md is outside the repository and not counted.'],
436
+ },
437
+ {
438
+ id: 'continue',
439
+ name: 'Continue',
440
+ families: [],
441
+ sources: ['https://docs.continue.dev/customize/deep-dives/rules'],
442
+ checkedAt: '2026-10-10',
443
+ rules: [
444
+ {
445
+ kind: 'frontmatter',
446
+ pattern: /^\.continue\/rules\/[^/]+\.md$/,
447
+ rootPrefixes: ['.continue/rules/'],
448
+ decide: (fm) => {
449
+ const always = fm.alwaysApply === true || str(fm.alwaysApply) === 'true';
450
+ const never = fm.alwaysApply === false || str(fm.alwaysApply) === 'false';
451
+ if (always)
452
+ return { mode: 'always', trigger: 'alwaysApply: true' };
453
+ if (has(fm, 'globs'))
454
+ return { mode: 'on-demand', trigger: `globs: ${str(fm.globs)}` };
455
+ if (!never)
456
+ return { mode: 'always', trigger: 'no globs' };
457
+ if (has(fm, 'description'))
458
+ return {
459
+ mode: 'on-demand',
460
+ trigger: 'agent decides from description',
461
+ descriptionOnly: true,
462
+ note: 'Description assumed to be in context so the agent can decide (inferred, not documented).',
463
+ };
464
+ return { mode: 'on-demand', trigger: 'alwaysApply: false (agent decides)' };
465
+ },
466
+ },
467
+ ],
468
+ limits: [],
469
+ notes: ['Rules in config.yaml or from the Continue hub are not counted.'],
470
+ },
471
+ {
472
+ id: 'aider',
473
+ name: 'Aider',
474
+ families: [],
475
+ sources: ['https://aider.chat/docs/usage/conventions.html'],
476
+ checkedAt: '2026-10-10',
477
+ rules: [
478
+ { kind: 'config-list', configPath: '.aider.conf.yml', key: 'read', trigger: 'read: in .aider.conf.yml' },
479
+ { kind: 'pattern', pattern: /^CONVENTIONS\.md$/, mode: 'manual', trigger: 'only with --read, /read or read: in .aider.conf.yml' },
480
+ ],
481
+ limits: [],
482
+ notes: ['Aider loads no instruction file by itself; files given with --read are sent with every request.'],
483
+ },
484
+ {
485
+ id: 'goose',
486
+ name: 'Goose',
487
+ families: [],
488
+ sources: ['https://goose-docs.ai/docs/guides/context-engineering/using-goosehints'],
489
+ checkedAt: '2026-10-10',
490
+ rules: [
491
+ {
492
+ kind: 'hierarchical',
493
+ names: ['AGENTS.md', '.goosehints'],
494
+ nested: 'on-demand',
495
+ nestedTrigger: 'when goose accesses files in',
496
+ },
497
+ ],
498
+ limits: [],
499
+ notes: [
500
+ 'Needs the Developer extension; CONTEXT_FILE_NAMES can change which files are read.',
501
+ 'Global ~/.config/goose/.goosehints is outside the repository and not counted.',
502
+ ],
503
+ },
504
+ {
505
+ id: 'amp',
506
+ name: 'Amp',
507
+ families: [],
508
+ sources: ['https://ampcode.com/docs/customize/agents-md'],
509
+ checkedAt: '2026-10-10',
510
+ rules: [
511
+ {
512
+ kind: 'hierarchical',
513
+ names: ['AGENTS.md', 'AGENT.md', 'CLAUDE.md'],
514
+ onePerDirectory: true,
515
+ nested: 'on-demand',
516
+ nestedTrigger: 'when Amp reads a file in',
517
+ importDepth: 1,
518
+ importGlobs: true,
519
+ note: 'AGENT.md and CLAUDE.md are fallbacks for directories without AGENTS.md; their order between each other, and following @-mentions one level deep, are assumptions.',
520
+ },
521
+ ],
522
+ limits: [],
523
+ notes: ['User and system AGENTS.md files (~/.config/amp, /etc/ampcode) are outside the repository and not counted.'],
524
+ },
525
+ {
526
+ id: 'opencode',
527
+ name: 'OpenCode',
528
+ families: [],
529
+ sources: ['https://opencode.ai/docs/rules/'],
530
+ checkedAt: '2026-10-10',
531
+ rules: [
532
+ {
533
+ kind: 'hierarchical',
534
+ names: ['AGENTS.md', 'CLAUDE.md'],
535
+ onePerDirectory: true,
536
+ nested: 'skip',
537
+ nestedTrigger: '',
538
+ note: 'CLAUDE.md is read only when there is no AGENTS.md.',
539
+ },
540
+ ],
541
+ limits: [],
542
+ notes: ['Extra files listed under instructions in opencode.json are not counted.'],
543
+ },
544
+ ];
545
+ export const TOOL_IDS = TOOLS.map((t) => t.id);
546
+ export function getTool(id) {
547
+ const tool = TOOLS.find((t) => t.id === id);
548
+ if (!tool)
549
+ throw new Error(`Unknown tool: ${id}`);
550
+ return tool;
551
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
1
+ import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { MAX_DEPTH, addImports, isInstructionFile, isSkippedDir } from '../core/files.js';
4
+ const MAX_BYTES = 2 * 1024 * 1024;
5
+ function readText(abs) {
6
+ try {
7
+ if (statSync(abs).size > MAX_BYTES)
8
+ return null;
9
+ return readFileSync(abs, 'utf8');
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ /** Finds agent instruction files under `root`, plus any in-repo files they @import. Read-only. */
16
+ export async function discover(root) {
17
+ const found = new Map();
18
+ const walk = (rel, depth) => {
19
+ if (depth > MAX_DEPTH)
20
+ return;
21
+ let entries;
22
+ try {
23
+ entries = readdirSync(join(root, rel), { withFileTypes: true });
24
+ }
25
+ catch {
26
+ return;
27
+ }
28
+ for (const e of entries) {
29
+ const path = rel ? `${rel}/${e.name}` : e.name;
30
+ if (e.isDirectory()) {
31
+ if (isSkippedDir(path))
32
+ continue;
33
+ walk(path, depth + 1);
34
+ }
35
+ else if ((e.isFile() || e.isSymbolicLink()) && isInstructionFile(path)) {
36
+ const text = readText(join(root, path));
37
+ if (text !== null)
38
+ found.set(path, { path, text });
39
+ }
40
+ }
41
+ };
42
+ walk('', 0);
43
+ // Pull in @imported files (for example a CLAUDE.md that contains @docs/agents.md).
44
+ await addImports(found, (rel) => {
45
+ const abs = join(root, rel);
46
+ return existsSync(abs) && !lstatSync(abs).isDirectory() ? readText(abs) : null;
47
+ });
48
+ return [...found.values()].sort((a, b) => a.path.localeCompare(b.path));
49
+ }
50
+ export function existsIn(root) {
51
+ return (path) => existsSync(join(root, path));
52
+ }
53
+ export function packageScripts(root) {
54
+ const text = readText(join(root, 'package.json'));
55
+ if (text === null)
56
+ return undefined;
57
+ try {
58
+ return new Set(Object.keys(JSON.parse(text).scripts ?? {}));
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ }