tunestra 0.3.0 → 0.5.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/README.md CHANGED
@@ -13,8 +13,8 @@
13
13
  > before v1.0.
14
14
 
15
15
  tunestra gives Claude Code, Codex and other AI coding agents **one shared source** of rules,
16
- skills, commands, MCP servers and environment variables, and generates each agent's native
17
- configuration from it.
16
+ skills, commands, MCP servers, environment variables and permissions, and generates each
17
+ agent's native configuration from it.
18
18
 
19
19
  The source is a chain of **presets**, from general to specific:
20
20
 
@@ -45,8 +45,8 @@ extends:
45
45
  agents: [claude] # add codex: [claude, codex]
46
46
  ```
47
47
 
48
- Commit `tunestra.yaml`, `.tunestra/`, `AGENTS.md`, `CLAUDE.md` and, when presets set `env`,
49
- `.claude/settings.json`. Everything else that tunestra generates is listed in `.gitignore`
48
+ Commit `tunestra.yaml`, `.tunestra/`, `AGENTS.md`, `CLAUDE.md` and, when presets set `env` or
49
+ `permissions`, `.claude/settings.json`. Everything else that tunestra generates is listed in `.gitignore`
50
50
  automatically.
51
51
 
52
52
  ## Principles
@@ -70,9 +70,26 @@ the manifest only holds metadata and overrides.
70
70
  rules/<key>.md # rules, one topic per file
71
71
  skills/<key>/SKILL.md # skills (Agent Skills format)
72
72
  commands/<key>.md # slash commands
73
+ agents/<key>.md # subagents
73
74
  mcp/<key>.yaml # MCP servers
74
75
  ```
75
76
 
77
+ A subagent is a markdown file with frontmatter; the text after it is the instructions:
78
+
79
+ ```md
80
+ ---
81
+ description: Reviews a diff for bugs and missing tests # required: when to use it
82
+ model: # optional: the model for each agent
83
+ claude: sonnet # otherwise the agent uses the model of the main session
84
+ codex: gpt-5.5
85
+ effort: medium # optional: low | medium | high | xhigh
86
+ readOnly: true # optional: the subagent must not change files
87
+ ---
88
+ Review the diff. Report each finding with a file and a line.
89
+ ```
90
+
91
+ The file name is the name of the subagent: lowercase letters, digits and single hyphens.
92
+
76
93
  ### Manifest: `tunestra.preset.yaml`
77
94
 
78
95
  ```yaml
@@ -90,6 +107,10 @@ overrides: # optional: patch inherited MCP servers
90
107
  QUEUE: TEAM
91
108
  env: # optional: environment variables of the agents
92
109
  MCP_TIMEOUT: 120000
110
+ permissions: # optional: rules in the syntax of Claude Code
111
+ allow: [Bash(npm test *)]
112
+ ask: [Bash(git push *)]
113
+ deny: [Read(./.env), Bash(rm -rf *)]
93
114
  ```
94
115
 
95
116
  ### Project: `tunestra.yaml`
@@ -139,8 +160,13 @@ chain once, before both of them.
139
160
  | Level instructions (`AGENTS.md`) | `instructions@<preset name>` | never collides: every level keeps its own | `exclude.rules` |
140
161
  | Skill | `name` from `SKILL.md`, otherwise the folder name | replaces it | `exclude.skills` |
141
162
  | Command | file name without `.md` | replaces it | `exclude.commands` |
163
+ | Subagent | file name without `.md` | replaces it | `exclude.subagents` |
142
164
  | MCP server | file name without `.yaml` | replaces it | `exclude.mcp` |
143
165
  | Environment variable | name in `env` | replaces it | `exclude.env` |
166
+ | Permission rule | the rule, like `Bash(npm test *)` | lists are joined: every level adds its rules | `exclude.permissions`, only for `allow` and `ask` |
167
+
168
+ A `deny` rule cannot be removed: the organization keeps a floor of what agents may never do.
169
+ Agents check `deny` first, so a `deny` rule on any level wins over an `allow` rule.
144
170
 
145
171
  `overrides.mcp.<key>` patches an inherited server instead of replacing it: `env` and `headers`
146
172
  are merged by key (the specific value wins); `command`, `args` and `url` are replaced.
@@ -157,7 +183,10 @@ are merged by key (the specific value wins); `command`, `args` and `url` are rep
157
183
  - after a patch, an MCP server has both `command` and `url`, or neither;
158
184
  - an `env` value refers to another variable, like `${TOKEN}`: agents do not expand it in
159
185
  their settings, so write the value itself and keep secrets out of presets;
160
- - `env` sets a variable that Claude Code ignores in project settings (see below).
186
+ - `env` sets a variable that Claude Code ignores in project settings (see below);
187
+ - `exclude.permissions` tries to remove a `deny` rule;
188
+ - a subagent has no `description` or no instructions, an unknown field, or a file name that
189
+ cannot be a subagent name.
161
190
 
162
191
  All problems are reported at once, in the order of the files, so you can fix them in one pass.
163
192
 
@@ -167,7 +196,7 @@ For every agent:
167
196
 
168
197
  | Path | Content | Committed |
169
198
  |---|---|---|
170
- | `AGENTS.md` | Index: hierarchy, rules, skills, commands, MCP servers | yes |
199
+ | `AGENTS.md` | Index: hierarchy, rules, skills, commands, MCP servers, subagents | yes |
171
200
  | `.agents/rules/<NN>-<level>-<key>.md` | Rule texts, ordered from specific to general | no |
172
201
  | `.agents/skills/<key>/` | Skills | no |
173
202
  | `.tunestra/state/` | What tunestra generated last time | no |
@@ -181,22 +210,27 @@ For every agent:
181
210
  | `.claude/rules/tunestra/<NN>-<level>-<key>.md` | Copies of the rules; Claude Code loads them in every session | no |
182
211
  | `.claude/skills/<key>` | Symlinks to `.agents/skills/<key>` | no |
183
212
  | `.claude/commands/<key>.md` | Commands | no |
213
+ | `.claude/agents/<key>.md` | Subagents | no |
184
214
  | `.mcp.json` | MCP servers | no |
185
- | `.claude/settings.json` | `env`, when presets set it | yes |
215
+ | `.claude/settings.json` | `env` and `permissions`, when presets set them | yes |
186
216
 
187
217
  `.claude/settings.json` is shared by the team, so tunestra writes it and you commit it. Keep
188
218
  your personal settings in `.claude/settings.local.json` or `~/.claude/settings.json`. A
189
219
  `.claude/settings.json` with other keys, like `hooks` or `model`, stops setup and asks you to
190
- move them; a file with only `$schema` and `env` is replaced with a warning, for example in a
220
+ move them; a file with only `$schema`, `env` and `permissions` is replaced with a warning, for example in a
191
221
  fresh clone where `.tunestra/state/` does not exist yet.
192
222
 
193
- Claude Code applies project `env` after you trust the folder. It ignores some variables in
223
+ Claude Code applies project `env` and `allow` rules after you trust the folder; `deny` and
224
+ `ask` rules apply right away. It ignores some variables in
194
225
  project settings: the ones that turn telemetry on or choose where it goes
195
226
  (`CLAUDE_CODE_ENABLE_TELEMETRY`, `OTEL_*_EXPORTER`, `OTEL_EXPORTER_OTLP_*` and others), and
196
227
  the ones that choose its own directories (`HOME`, `XDG_*`, `CLAUDE_CONFIG_DIR`). setup stops
197
228
  on them, because they would not work; set them in managed settings, `~/.claude/settings.json`
198
229
  or your shell. Values that turn telemetry off, like `OTEL_LOGS_EXPORTER: none`, are allowed.
199
230
 
231
+ A read-only subagent of Claude Code loses the tools that change files: `Write`, `Edit`,
232
+ `NotebookEdit` and `Bash`. It keeps the tools that read and search, and MCP tools.
233
+
200
234
  ### Codex
201
235
 
202
236
  Codex reads `AGENTS.md` and `.agents/skills/` by itself. It does not follow the links in
@@ -205,15 +239,29 @@ Codex reads `AGENTS.md` and `.agents/skills/` by itself. It does not follow the
205
239
  | Path | Content | Committed |
206
240
  |---|---|---|
207
241
  | `.codex/config.toml` | `developer_instructions` with every rule, from specific to general, `[shell_environment_policy] set` with `env`, and `[mcp_servers]` | no |
242
+ | `.codex/rules/tunestra.rules` | `deny` and `ask` rules for shell commands as `prefix_rule`: `forbidden` and `prompt` | no |
208
243
  | `.agents/skills/<command>/` | Each command as a skill that runs only when you call it: `$release-notes` | no |
244
+ | `.codex/agents/<key>.toml` | Subagents | no |
209
245
 
210
246
  Codex reads `.codex/config.toml` only in a trusted project: trust the project when Codex asks.
211
247
  Codex gets `env` as the environment of the commands it runs; it does not change how Codex
212
248
  itself works, and Codex ignores telemetry settings in a project.
249
+
250
+ Codex rules match only the start of a shell command, so tunestra converts only `Bash` rules
251
+ like `Bash(git push *)` or `Bash(rm -rf)`. A rule without a wildcard also becomes a prefix,
252
+ which is stricter for `deny` and `ask`. Other rules (`Read`, `Edit`, `WebFetch`, MCP tools, and
253
+ `Bash` rules with wildcards in the middle or shell syntax) are skipped with a note. `allow`
254
+ rules are skipped too: in Codex, an allowed command runs outside the sandbox without asking,
255
+ which is more than an `allow` rule of Claude Code means.
213
256
  Codex has no `type` for MCP servers and does not support SSE: an SSE server is skipped with a
214
257
  note, and `headers` become `http_headers`. tunestra owns the whole `.codex/config.toml`; keep
215
258
  your personal Codex settings in `~/.codex/config.toml`.
216
259
 
260
+ A Codex subagent gets the sandbox, the permissions and the MCP servers of the main session; its
261
+ file can change only the model and the instructions. So Codex cannot make a subagent
262
+ read-only: tunestra turns off its shell (`features.shell_tool = false`) and asks it not to
263
+ edit files in the instructions, and setup says so in a note.
264
+
217
265
  `<NN>` orders rules from specific to general: `10` project, `20` team, `30` domain, `40` base.
218
266
  Level instructions get the key `instructions`, and the preset name when it differs from the
219
267
  level: `20-team-instructions.md`, but `20-team-instructions-frontend.md` for a `team` preset
@@ -264,7 +312,7 @@ and tells you where to move their text.
264
312
  |---|---|
265
313
  | v0.1 | Inheritance for Claude Code |
266
314
  | v0.2 | Codex (this document) |
267
- | v0.3 | `env`, then permissions, subagents and a personal `local` level |
315
+ | v0.3 | `env` and permissions, then subagents and a personal `local` level |
268
316
  | v0.4 | Spaces, npm sources, lockfile |
269
317
  | v0.5 | `required` / `locked` policies, CI check |
270
318
  | v0.6 | Hooks |
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as VERSION } from "./src-BNeh64fK.mjs";
2
+ import { t as VERSION } from "./src-CZK2Ixbt.mjs";
3
3
  import { Command } from "@commander-js/extra-typings";
4
4
  import pc from "picocolors";
5
5
  import { copyFile, lstat, mkdir, readFile, readdir, readlink, rename, rm, rmdir, stat, symlink, unlink, writeFile } from "node:fs/promises";
@@ -338,13 +338,22 @@ const envSchema = z.record(z.string(), envValue).superRefine((env, context) => {
338
338
  path: [name]
339
339
  });
340
340
  });
341
+ const permissionRules = z.array(z.string().regex(/^[A-Za-z][\w*-]*(\(.+\))?$/, "Write a rule as Tool or Tool(specifier), like Bash(npm test *)")).readonly();
342
+ const permissionsSchema = z.strictObject({
343
+ allow: permissionRules.optional(),
344
+ ask: permissionRules.optional(),
345
+ deny: permissionRules.optional()
346
+ });
341
347
  const skillName = z.string().regex(SKILL_NAME_PATTERN, "Use lowercase letters, digits and single hyphens").max(64);
342
348
  const excludeSchema = z.strictObject({
343
349
  rules: keys.optional(),
344
350
  skills: keys.optional(),
345
351
  commands: keys.optional(),
346
352
  mcp: keys.optional(),
347
- env: keys.optional()
353
+ subagents: keys.optional(),
354
+ env: keys.optional(),
355
+ /** Inherited `allow` and `ask` rules; a `deny` rule cannot be removed. */
356
+ permissions: keys.optional()
348
357
  });
349
358
  const mcpPatchSchema = z.strictObject({
350
359
  command: z.string().optional(),
@@ -361,7 +370,8 @@ const presetManifestSchema = z.strictObject({
361
370
  extends: z.array(z.string().min(1)).readonly().default([]),
362
371
  exclude: excludeSchema.default({}),
363
372
  overrides: overridesSchema.default({}),
364
- env: envSchema.default({})
373
+ env: envSchema.default({}),
374
+ permissions: permissionsSchema.default({})
365
375
  });
366
376
  const projectConfigSchema = z.strictObject({
367
377
  name: z.string().min(1).optional(),
@@ -369,7 +379,8 @@ const projectConfigSchema = z.strictObject({
369
379
  agents: z.array(z.enum(Agent)).min(1).readonly().default([Agent.Claude]),
370
380
  exclude: excludeSchema.default({}),
371
381
  overrides: overridesSchema.default({}),
372
- env: envSchema.default({})
382
+ env: envSchema.default({}),
383
+ permissions: permissionsSchema.default({})
373
384
  });
374
385
  const mcpServerSchema = z.strictObject({
375
386
  description: z.string().optional(),
@@ -453,6 +464,15 @@ async function mapSettled(items, map) {
453
464
  throw buildResultsError(results);
454
465
  }
455
466
  //#endregion
467
+ //#region src/shared/constants/subagents.ts
468
+ /** Reasoning effort of a subagent: the levels that both Claude Code and Codex accept. */
469
+ const SubagentEffort = {
470
+ Low: "low",
471
+ Medium: "medium",
472
+ High: "high",
473
+ ExtraHigh: "xhigh"
474
+ };
475
+ //#endregion
456
476
  //#region src/shared/schemas/frontmatter.ts
457
477
  const ruleFrontmatterSchema = z.looseObject({ description: z.string().optional() });
458
478
  const skillFrontmatterSchema = z.looseObject({
@@ -460,6 +480,15 @@ const skillFrontmatterSchema = z.looseObject({
460
480
  description: z.string().min(1)
461
481
  });
462
482
  const commandFrontmatterSchema = z.looseObject({ description: z.string().optional() });
483
+ /** The model names differ between agents, so a subagent sets one for each agent. */
484
+ const subagentModelSchema = z.partialRecord(z.enum(Agent), z.string().min(1));
485
+ /** tunestra writes the files of the agents itself, so it knows every field here. */
486
+ const subagentFrontmatterSchema = z.strictObject({
487
+ description: z.string().min(1),
488
+ model: subagentModelSchema.default({}),
489
+ effort: z.enum(SubagentEffort).optional(),
490
+ readOnly: z.boolean().default(false)
491
+ });
463
492
  //#endregion
464
493
  //#region src/presets/frontmatter.ts
465
494
  /**
@@ -592,6 +621,28 @@ function findDuplicateSkills(skills, dir) {
592
621
  return problems;
593
622
  }
594
623
  //#endregion
624
+ //#region src/presets/artifacts/subagents.ts
625
+ /** Subagents live in `agents/`, like in Claude Code and Codex. */
626
+ async function loadSubagents(dir) {
627
+ return mapSettled(await listFiles(dir, "agents/*.md"), (source) => loadSubagent(dir, source));
628
+ }
629
+ async function loadSubagent(dir, source) {
630
+ const file = join(dir, source);
631
+ const { data, body } = parseFrontmatter(await readFile(file, "utf8"), file);
632
+ const frontmatter = validate(data, subagentFrontmatterSchema, `frontmatter in ${file}`);
633
+ const key = validate(basename(source, ".md"), skillName, `subagent file ${source}`);
634
+ if (body.trim().length > 0 === false) throw new TunestraError(`${file} has no instructions: write them after the frontmatter`);
635
+ return {
636
+ key,
637
+ source,
638
+ description: frontmatter.description,
639
+ model: frontmatter.model,
640
+ effort: frontmatter.effort,
641
+ readOnly: frontmatter.readOnly,
642
+ body
643
+ };
644
+ }
645
+ //#endregion
595
646
  //#region src/presets/artifacts/load-artifacts.ts
596
647
  /** Loads every kind of artifact at the same time and reports the problems of all of them. */
597
648
  async function loadArtifacts(dir, presetName) {
@@ -599,19 +650,22 @@ async function loadArtifacts(dir, presetName) {
599
650
  const skillsLoading = loadSkills(dir);
600
651
  const commandsLoading = loadCommands(dir);
601
652
  const mcpLoading = loadMcpServers(dir);
653
+ const subagentsLoading = loadSubagents(dir);
602
654
  const results = await Promise.allSettled([
603
655
  rulesLoading,
604
656
  skillsLoading,
605
657
  commandsLoading,
606
- mcpLoading
658
+ mcpLoading,
659
+ subagentsLoading
607
660
  ]);
608
661
  if (isEveryFulfilled(results) === false) throw buildResultsError(results);
609
- const [rulesResult, skillsResult, commandsResult, mcpResult] = results;
662
+ const [rulesResult, skillsResult, commandsResult, mcpResult, subagentsResult] = results;
610
663
  return {
611
664
  rules: rulesResult.value,
612
665
  skills: skillsResult.value,
613
666
  commands: commandsResult.value,
614
- mcp: mcpResult.value
667
+ mcp: mcpResult.value,
668
+ subagents: subagentsResult.value
615
669
  };
616
670
  }
617
671
  //#endregion
@@ -635,6 +689,34 @@ function createEnvVariables(env, manifestPath, artifactsDir) {
635
689
  }));
636
690
  }
637
691
  //#endregion
692
+ //#region src/shared/constants/permissions.ts
693
+ /** What an agent does with a tool call that matches a permission rule. */
694
+ const PermissionDecision = {
695
+ /** Run without asking. */
696
+ Allow: "allow",
697
+ /** Ask every time. */
698
+ Ask: "ask",
699
+ /** Never run. Agents check deny first, so a deny rule always wins. */
700
+ Deny: "deny"
701
+ };
702
+ //#endregion
703
+ //#region src/presets/permissions.ts
704
+ const DECISIONS$1 = Object.values(PermissionDecision);
705
+ /** The `permissions` lists of a manifest as one list of rules, in the order of the file. */
706
+ function createPermissionRules(permissions, manifestPath, artifactsDir) {
707
+ const source = relativePosix(artifactsDir, manifestPath);
708
+ const rules = [];
709
+ for (const decision of DECISIONS$1) {
710
+ const keys = permissions[decision] ?? [];
711
+ for (const key of keys) rules.push({
712
+ key,
713
+ decision,
714
+ source
715
+ });
716
+ }
717
+ return rules;
718
+ }
719
+ //#endregion
638
720
  //#region src/presets/load-project.ts
639
721
  async function loadProject(dir) {
640
722
  const root = resolve(dir);
@@ -646,6 +728,7 @@ async function loadProject(dir) {
646
728
  const artifactsDir = join(root, PROJECT_DIR);
647
729
  const artifacts = await loadArtifacts(artifactsDir, name);
648
730
  const env = createEnvVariables(config.env, configPath, artifactsDir);
731
+ const permissions = createPermissionRules(config.permissions, configPath, artifactsDir);
649
732
  return {
650
733
  ...config,
651
734
  name,
@@ -655,7 +738,8 @@ async function loadProject(dir) {
655
738
  artifactsDir,
656
739
  remote: void 0,
657
740
  ...artifacts,
658
- env
741
+ env,
742
+ permissions
659
743
  };
660
744
  }
661
745
  //#endregion
@@ -861,6 +945,7 @@ async function loadPreset(dir) {
861
945
  const manifest = await readYaml(manifestPath, presetManifestSchema, "preset manifest");
862
946
  const artifacts = await loadArtifacts(root, manifest.name);
863
947
  const env = createEnvVariables(manifest.env, manifestPath, root);
948
+ const permissions = createPermissionRules(manifest.permissions, manifestPath, root);
864
949
  return {
865
950
  ...manifest,
866
951
  description: manifest.description,
@@ -868,7 +953,8 @@ async function loadPreset(dir) {
868
953
  artifactsDir: root,
869
954
  remote: void 0,
870
955
  ...artifacts,
871
- env
956
+ env,
957
+ permissions
872
958
  };
873
959
  }
874
960
  //#endregion
@@ -1054,14 +1140,52 @@ function compareByName([a], [b]) {
1054
1140
  //#endregion
1055
1141
  //#region src/agents/claude/settings-json.ts
1056
1142
  const SETTINGS_SCHEMA = "https://json.schemastore.org/claude-code-settings.json";
1143
+ const DECISIONS = Object.values(PermissionDecision);
1057
1144
  /** The keys that tunestra writes; a file with only these keys is a generated one. */
1058
- const SETTINGS_KEYS = ["$schema", "env"];
1059
- function renderSettingsJson(env) {
1060
- const variables = buildEnvMap(env);
1061
- return `${JSON.stringify({
1062
- $schema: SETTINGS_SCHEMA,
1063
- env: variables
1064
- }, null, 2)}\n`;
1145
+ const SETTINGS_KEYS = [
1146
+ "$schema",
1147
+ "env",
1148
+ "permissions"
1149
+ ];
1150
+ /** `.claude/settings.json` with sorted keys and lists, so that its diff stays small. */
1151
+ function renderSettingsJson(settings) {
1152
+ const content = { $schema: SETTINGS_SCHEMA };
1153
+ const hasEnv = settings.env.length > 0;
1154
+ const hasPermissions = settings.permissions.length > 0;
1155
+ if (hasEnv) content.env = buildEnvMap(settings.env);
1156
+ if (hasPermissions) content.permissions = buildPermissionLists(settings.permissions);
1157
+ return `${JSON.stringify(content, null, 2)}\n`;
1158
+ }
1159
+ /** Only the lists that have rules; a rule repeated on several levels is written once. */
1160
+ function buildPermissionLists(permissions) {
1161
+ const lists = {};
1162
+ for (const decision of DECISIONS) {
1163
+ const keys = permissions.filter((resolved) => resolved.artifact.decision === decision).map((resolved) => resolved.artifact.key);
1164
+ const sortedKeys = [...new Set(keys)].toSorted();
1165
+ if (sortedKeys.length > 0) lists[decision] = sortedKeys;
1166
+ }
1167
+ return lists;
1168
+ }
1169
+ //#endregion
1170
+ //#region src/agents/claude/subagent-md.ts
1171
+ /** Tools that change files; a read-only subagent keeps the tools that read and search. */
1172
+ const WRITING_TOOLS = [
1173
+ "Write",
1174
+ "Edit",
1175
+ "NotebookEdit",
1176
+ "Bash"
1177
+ ];
1178
+ /** `.claude/agents/<name>.md`: the frontmatter of Claude Code and the instructions. */
1179
+ function renderSubagentMd(subagent) {
1180
+ const { key, description, model, effort, readOnly, body } = subagent;
1181
+ const frontmatter = {
1182
+ name: key,
1183
+ description
1184
+ };
1185
+ if (typeof model.claude === "string") frontmatter.model = model.claude;
1186
+ if (typeof effort === "string") frontmatter.effort = effort;
1187
+ if (readOnly) frontmatter.disallowedTools = WRITING_TOOLS;
1188
+ return `---\n${stringify(frontmatter)}---\n${body}`;
1065
1189
  }
1066
1190
  //#endregion
1067
1191
  //#region src/agents/claude/claude-adapter.ts
@@ -1069,17 +1193,18 @@ const CLAUDE_FILE = "CLAUDE.md";
1069
1193
  const RULES_DIR = ".claude/rules/tunestra";
1070
1194
  const SKILLS_DIR = ".claude/skills";
1071
1195
  const COMMANDS_DIR = ".claude/commands";
1196
+ const SUBAGENTS_DIR$1 = ".claude/agents";
1072
1197
  const MCP_FILE = ".mcp.json";
1073
1198
  const MCP_SOURCE = "the mcp/ folders of tunestra.yaml and its presets";
1074
1199
  const SETTINGS_FILE = ".claude/settings.json";
1075
- const SETTINGS_SOURCE = "env in tunestra.yaml and its presets";
1200
+ const SETTINGS_SOURCE = "env and permissions in tunestra.yaml and its presets";
1076
1201
  const LOCAL_SETTINGS_FILE = ".claude/settings.local.json";
1077
1202
  /**
1078
1203
  * Claude Code reads CLAUDE.md, so it imports AGENTS.md. Rules are copied to `.claude/rules`,
1079
1204
  * which Claude Code loads in every session; a symlink to a directory outside `.claude`
1080
1205
  * would need an approval. Skills are symlinks to the shared `.agents/skills`. Environment
1081
- * variables go to `.claude/settings.json`, which the team commits; `settings.local.json`
1082
- * stays for personal settings.
1206
+ * variables and permissions go to `.claude/settings.json`, which the team commits;
1207
+ * `settings.local.json` stays for personal settings.
1083
1208
  */
1084
1209
  const claudeAdapter = {
1085
1210
  agent: Agent.Claude,
@@ -1097,11 +1222,15 @@ function planClaude(context) {
1097
1222
  const source = describeArtifactSource(projectDir, command.origin, command.artifact.source);
1098
1223
  return createFileOutput(path, command.artifact.content, source);
1099
1224
  });
1225
+ const subagentFiles = resolution.subagents.map((subagent) => {
1226
+ return createFileOutput(`${SUBAGENTS_DIR$1}/${subagent.artifact.key}.md`, renderSubagentMd(subagent.artifact), describeArtifactSource(projectDir, subagent.origin, subagent.artifact.source));
1227
+ });
1100
1228
  const outputs = [
1101
1229
  claudeFile,
1102
1230
  ...ruleCopies,
1103
1231
  ...skillLinks,
1104
- ...commandFiles
1232
+ ...commandFiles,
1233
+ ...subagentFiles
1105
1234
  ];
1106
1235
  if (resolution.mcp.length > 0) {
1107
1236
  const mcpJson = renderMcpJson(resolution.mcp);
@@ -1109,8 +1238,8 @@ function planClaude(context) {
1109
1238
  outputs.push(mcpFile);
1110
1239
  }
1111
1240
  const protectedPaths = [CLAUDE_FILE];
1112
- if (resolution.env.length > 0) {
1113
- const settingsFile = planSettingsFile(resolution.env);
1241
+ if (resolution.env.length > 0 || resolution.permissions.length > 0) {
1242
+ const settingsFile = planSettingsFile(resolution.env, resolution.permissions);
1114
1243
  outputs.push(settingsFile);
1115
1244
  protectedPaths.push(SETTINGS_FILE);
1116
1245
  }
@@ -1120,6 +1249,7 @@ function planClaude(context) {
1120
1249
  `${RULES_DIR}/`,
1121
1250
  `${SKILLS_DIR}/`,
1122
1251
  `${COMMANDS_DIR}/`,
1252
+ `${SUBAGENTS_DIR$1}/`,
1123
1253
  MCP_FILE
1124
1254
  ],
1125
1255
  protectedPaths,
@@ -1127,10 +1257,13 @@ function planClaude(context) {
1127
1257
  };
1128
1258
  }
1129
1259
  /** Claude Code drops some variables from project settings; setup says so instead. */
1130
- function planSettingsFile(env) {
1260
+ function planSettingsFile(env, permissions) {
1131
1261
  const problems = env.filter((variable) => isIgnoredByClaude(variable.artifact.key, variable.artifact.value)).map((variable) => `${variable.origin.name}: env sets ${variable.artifact.key}, but Claude Code ignores it in project settings. Set it in managed settings, ~/.claude/settings.json or your shell`);
1132
1262
  if (problems.length > 0) throw createProblemsError(problems);
1133
- const content = renderSettingsJson(env);
1263
+ const content = renderSettingsJson({
1264
+ env,
1265
+ permissions
1266
+ });
1134
1267
  const file = createFileOutput(SETTINGS_FILE, content, SETTINGS_SOURCE);
1135
1268
  const ownership = {
1136
1269
  keys: SETTINGS_KEYS,
@@ -1185,7 +1318,7 @@ function planCommandSkill(projectDir, command) {
1185
1318
  return [createFileOutput(skillPath, skill, source), createFileOutput(policyPath, OPENAI_YAML, source)];
1186
1319
  }
1187
1320
  //#endregion
1188
- //#region src/agents/codex/config-toml.ts
1321
+ //#region src/agents/codex/toml-text.ts
1189
1322
  /** Starts and ends a multi-line literal string, where TOML escapes nothing. */
1190
1323
  const LITERAL_DELIMITER = "'''";
1191
1324
  /**
@@ -1197,6 +1330,18 @@ const LITERAL_DELIMITER = "'''";
1197
1330
  */
1198
1331
  const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000B-\u001F\u007F]/;
1199
1332
  /**
1333
+ * A key with a long text, like `developer_instructions`. The text is a literal string, so
1334
+ * that people can read it; a text that a literal string cannot hold is escaped.
1335
+ */
1336
+ function renderTomlText(key, text) {
1337
+ const hasDelimiter = text.includes(LITERAL_DELIMITER);
1338
+ const hasControlCharacter = CONTROL_CHARACTER_PATTERN.test(text);
1339
+ if (hasDelimiter || hasControlCharacter) return stringify$1({ [key]: text });
1340
+ return `${key} = ${LITERAL_DELIMITER}\n${text}${LITERAL_DELIMITER}`;
1341
+ }
1342
+ //#endregion
1343
+ //#region src/agents/codex/config-toml.ts
1344
+ /**
1200
1345
  * `.codex/config.toml`: the rules as `developer_instructions`, which Codex adds to every
1201
1346
  * session, the environment of the commands that Codex runs, and the MCP servers. The rules
1202
1347
  * are a literal string, so that people can read them.
@@ -1204,7 +1349,7 @@ const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000B-\u001F\u007F]/;
1204
1349
  function renderConfigToml(config) {
1205
1350
  const parts = [GENERATED_HASH_NOTICE];
1206
1351
  if (typeof config.instructions === "string") {
1207
- const line = renderInstructions$1(config.instructions);
1352
+ const line = renderTomlText("developer_instructions", config.instructions);
1208
1353
  parts.push(line);
1209
1354
  }
1210
1355
  const tables = {};
@@ -1224,12 +1369,6 @@ function renderConfigToml(config) {
1224
1369
  }
1225
1370
  return `${parts.join("\n\n")}\n`;
1226
1371
  }
1227
- function renderInstructions$1(text) {
1228
- const hasDelimiter = text.includes(LITERAL_DELIMITER);
1229
- const hasControlCharacter = CONTROL_CHARACTER_PATTERN.test(text);
1230
- if (hasDelimiter || hasControlCharacter) return stringify$1({ developer_instructions: text });
1231
- return `developer_instructions = ${LITERAL_DELIMITER}\n${text}${LITERAL_DELIMITER}`;
1232
- }
1233
1372
  /** Codex picks the transport by `command` or `url` and has no `type`. */
1234
1373
  function convertMcpConfig(config) {
1235
1374
  const { type: _type, headers, ...server } = config;
@@ -1240,15 +1379,112 @@ function convertMcpConfig(config) {
1240
1379
  };
1241
1380
  }
1242
1381
  //#endregion
1382
+ //#region src/agents/codex/exec-rules.ts
1383
+ /** A Bash rule of Claude Code with a specifier: `Bash(npm test *)`. */
1384
+ const BASH_RULE_START = "Bash(";
1385
+ const BASH_RULE_END = ")";
1386
+ /**
1387
+ * Shell syntax and wildcards that a Codex prefix rule cannot express.
1388
+ *
1389
+ * Matches: "npm test && rm", "echo $HOME", "git * --force", "ls > out", "say 'hi'"
1390
+ * Does not match: "npm test", "git push --force", "rm -rf"
1391
+ */
1392
+ const SHELL_SYNTAX_PATTERN = /["'`$\\|&;<>(){}*?[\]~]/;
1393
+ const WHITESPACE_PATTERN = /\s+/;
1394
+ /** A trailing ` *` or the older `:*` means "this command with any arguments". */
1395
+ const ANY_ARGUMENTS_SUFFIXES = [" *", ":*"];
1396
+ /**
1397
+ * Codex names for the decisions it gets. `allow` stays out: in Codex it runs a command
1398
+ * outside the sandbox without asking, which is more than an `allow` rule of Claude Code means.
1399
+ */
1400
+ const CODEX_DECISIONS = {
1401
+ [PermissionDecision.Deny]: "forbidden",
1402
+ [PermissionDecision.Ask]: "prompt"
1403
+ };
1404
+ /**
1405
+ * The words of a Bash rule that Codex can match by prefix, or `undefined` when it cannot.
1406
+ * A rule without a wildcard becomes a prefix too: for `deny` and `ask` this is stricter.
1407
+ */
1408
+ function convertBashRule(rule) {
1409
+ if ((rule.startsWith(BASH_RULE_START) && rule.endsWith(BASH_RULE_END)) === false) return;
1410
+ const specifier = rule.slice(5, -1);
1411
+ const suffix = ANY_ARGUMENTS_SUFFIXES.find((ending) => specifier.endsWith(ending));
1412
+ const command = typeof suffix === "string" ? specifier.slice(0, -suffix.length) : specifier;
1413
+ if (SHELL_SYNTAX_PATTERN.test(command)) return;
1414
+ const words = command.trim().split(WHITESPACE_PATTERN);
1415
+ if (words.every((word) => word.length > 0)) return words;
1416
+ }
1417
+ function hasCodexDecision(decision) {
1418
+ return decision in CODEX_DECISIONS;
1419
+ }
1420
+ function createExecRule(resolved, decision, pattern) {
1421
+ return {
1422
+ pattern,
1423
+ decision: CODEX_DECISIONS[decision],
1424
+ justification: `${resolved.artifact.key}: ${decision} from ${resolved.origin.name}`
1425
+ };
1426
+ }
1427
+ /** `.codex/rules/tunestra.rules` in the Starlark format of Codex. */
1428
+ function renderExecRules(rules) {
1429
+ const blocks = rules.map(renderExecRule);
1430
+ return `${[GENERATED_HASH_NOTICE, ...blocks].join("\n\n")}\n`;
1431
+ }
1432
+ /** JSON strings are valid Starlark strings. */
1433
+ function renderExecRule(rule) {
1434
+ const pattern = rule.pattern.map((word) => JSON.stringify(word)).join(", ");
1435
+ const decision = JSON.stringify(rule.decision);
1436
+ const justification = JSON.stringify(rule.justification);
1437
+ return [
1438
+ "prefix_rule(",
1439
+ ` pattern = [${pattern}],`,
1440
+ ` decision = ${decision},`,
1441
+ ` justification = ${justification},`,
1442
+ ")"
1443
+ ].join("\n");
1444
+ }
1445
+ //#endregion
1446
+ //#region src/agents/codex/subagent-toml.ts
1447
+ /**
1448
+ * Codex has no read-only mode for a subagent: it can turn off the shell, but the subagent
1449
+ * still edits files with patches. So the instructions ask it not to.
1450
+ */
1451
+ const READ_ONLY_INSTRUCTION = "Do not create, edit or delete files.";
1452
+ /**
1453
+ * `.codex/agents/<name>.toml`. Codex gives a subagent the sandbox, the permissions and the
1454
+ * MCP servers of the main session, so the file has only the model and the instructions.
1455
+ */
1456
+ function renderSubagentToml(subagent) {
1457
+ const { key, description, model, effort, readOnly, body } = subagent;
1458
+ const fields = {
1459
+ name: key,
1460
+ description
1461
+ };
1462
+ if (typeof model.codex === "string") fields.model = model.codex;
1463
+ if (typeof effort === "string") fields.model_reasoning_effort = effort;
1464
+ const instructions = readOnly ? `${READ_ONLY_INSTRUCTION}\n\n${body}` : body;
1465
+ const parts = [
1466
+ GENERATED_HASH_NOTICE,
1467
+ stringify$1(fields).trimEnd(),
1468
+ renderTomlText("developer_instructions", instructions)
1469
+ ];
1470
+ if (readOnly) parts.push(stringify$1({ features: { shell_tool: false } }).trimEnd());
1471
+ return `${parts.join("\n\n")}\n`;
1472
+ }
1473
+ //#endregion
1243
1474
  //#region src/agents/codex/codex-adapter.ts
1244
1475
  const CONFIG_FILE = ".codex/config.toml";
1245
1476
  const CONFIG_SOURCE = "the rules/ and mcp/ folders and env of tunestra.yaml and its presets";
1477
+ const EXEC_RULES_FILE = ".codex/rules/tunestra.rules";
1478
+ const EXEC_RULES_SOURCE = "permissions in tunestra.yaml and its presets";
1479
+ const SUBAGENTS_DIR = ".codex/agents";
1246
1480
  const INSTRUCTIONS_INTRO = "Rules of this project, ordered from specific to general. When two rules conflict, follow the one that comes first.";
1247
- const TRUST_NOTE = "Codex reads .codex/config.toml only in a trusted project: trust the project when Codex asks.";
1481
+ const TRUST_NOTE = "Codex reads .codex/ only in a trusted project: trust the project when Codex asks.";
1248
1482
  /**
1249
1483
  * Codex reads AGENTS.md and the skills in `.agents/skills` by itself. Rules go to
1250
1484
  * `developer_instructions` in `.codex/config.toml`, because Codex does not follow the links
1251
- * in AGENTS.md; MCP servers go to the same file. Commands become skills.
1485
+ * in AGENTS.md; MCP servers and env go to the same file. Commands become skills, and
1486
+ * permission rules for shell commands become `.codex/rules/tunestra.rules`. Subagents go
1487
+ * to `.codex/agents`.
1252
1488
  */
1253
1489
  const codexAdapter = {
1254
1490
  agent: Agent.Codex,
@@ -1265,25 +1501,86 @@ function planCodex(context) {
1265
1501
  return false;
1266
1502
  }).flatMap((command) => planCommandSkill(projectDir, command));
1267
1503
  outputs.push(...commandSkills);
1268
- const configFile = planConfigFile(ruleFiles, resolution.mcp.filter((server) => {
1504
+ const servers = resolution.mcp.filter((server) => {
1269
1505
  if (server.artifact.config.type === "sse") {
1270
1506
  notes.push(`Codex skips the MCP server "${server.artifact.key}": Codex does not support SSE.`);
1271
1507
  return false;
1272
1508
  }
1273
1509
  return true;
1274
- }), resolution.env);
1275
- if (configFile === void 0) return {
1510
+ });
1511
+ const subagentFiles = resolution.subagents.map((subagent) => {
1512
+ return createFileOutput(`${SUBAGENTS_DIR}/${subagent.artifact.key}.toml`, renderSubagentToml(subagent.artifact), describeArtifactSource(projectDir, subagent.origin, subagent.artifact.source));
1513
+ });
1514
+ const readOnlySubagents = resolution.subagents.filter((subagent) => subagent.artifact.readOnly).map((subagent) => subagent.artifact.key);
1515
+ const hasReadOnlySubagents = readOnlySubagents.length > 0;
1516
+ outputs.push(...subagentFiles);
1517
+ if (hasReadOnlySubagents) {
1518
+ const names = readOnlySubagents.join(", ");
1519
+ notes.push(`Codex cannot make subagents read-only: ${names}. It turns off their shell and asks them not to edit files.`);
1520
+ }
1521
+ const configFile = planConfigFile(ruleFiles, servers, resolution.env);
1522
+ const execRules = planExecRules(resolution.permissions);
1523
+ const ignoredPaths = [];
1524
+ notes.push(...execRules.notes);
1525
+ if (typeof configFile === "object") {
1526
+ outputs.push(configFile);
1527
+ ignoredPaths.push(CONFIG_FILE);
1528
+ }
1529
+ if (typeof execRules.file === "object") {
1530
+ outputs.push(execRules.file);
1531
+ ignoredPaths.push(EXEC_RULES_FILE);
1532
+ }
1533
+ if (subagentFiles.length > 0) ignoredPaths.push(`${SUBAGENTS_DIR}/`);
1534
+ if (ignoredPaths.length > 0) notes.push(TRUST_NOTE);
1535
+ return {
1276
1536
  outputs,
1277
- ignoredPaths: [],
1537
+ ignoredPaths,
1278
1538
  protectedPaths: [],
1279
1539
  notes
1280
1540
  };
1281
- outputs.push(configFile);
1282
- notes.push(TRUST_NOTE);
1541
+ }
1542
+ /**
1543
+ * `deny` and `ask` rules for Bash commands become prefix rules of Codex. Codex has no rules
1544
+ * for files, the web or MCP tools, and `allow` would mean more in Codex, so those are skipped.
1545
+ */
1546
+ function planExecRules(permissions) {
1547
+ const execRules = [];
1548
+ const skippedRules = [];
1549
+ const allowRules = [];
1550
+ for (const resolved of permissions) {
1551
+ const { key, decision } = resolved.artifact;
1552
+ if (hasCodexDecision(decision) === false) {
1553
+ allowRules.push(key);
1554
+ continue;
1555
+ }
1556
+ const pattern = convertBashRule(key);
1557
+ if (pattern === void 0) {
1558
+ skippedRules.push(key);
1559
+ continue;
1560
+ }
1561
+ const execRule = createExecRule(resolved, decision, pattern);
1562
+ execRules.push(execRule);
1563
+ }
1564
+ const notes = [];
1565
+ const hasSkippedRules = skippedRules.length > 0;
1566
+ const hasAllowRules = allowRules.length > 0;
1567
+ if (hasSkippedRules) {
1568
+ const rules = skippedRules.join(", ");
1569
+ notes.push(`Codex skips permission rules that it cannot enforce: ${rules}. Codex rules match only the start of a shell command.`);
1570
+ }
1571
+ if (hasAllowRules) {
1572
+ const rules = allowRules.join(", ");
1573
+ notes.push(`Codex skips allow rules: ${rules}. In Codex, an allowed command runs outside the sandbox without asking.`);
1574
+ }
1575
+ if (execRules.length > 0) {
1576
+ const content = renderExecRules(execRules);
1577
+ return {
1578
+ file: createFileOutput(EXEC_RULES_FILE, content, EXEC_RULES_SOURCE),
1579
+ notes
1580
+ };
1581
+ }
1283
1582
  return {
1284
- outputs,
1285
- ignoredPaths: [CONFIG_FILE],
1286
- protectedPaths: [],
1583
+ file: void 0,
1287
1584
  notes
1288
1585
  };
1289
1586
  }
@@ -1398,6 +1695,7 @@ function renderLevelLine(rule) {
1398
1695
  const HIERARCHY_INTRO = "Instructions are ordered from specific to general. When two instructions conflict, follow the one from the higher level in this list.";
1399
1696
  const RULES_INTRO = "Always follow these rules. Claude Code and Codex load them automatically; other agents should read them before starting work.";
1400
1697
  const SKILLS_INTRO = "Use a skill when the task matches its description.";
1698
+ const SUBAGENTS_INTRO = "Hand a task to a subagent when the task matches its description.";
1401
1699
  const ENV_INTRO = "Agents set these variables in every session, also for the commands they run. The values are in the presets.";
1402
1700
  const CHANGING_SECTION = [
1403
1701
  "## Changing this setup",
@@ -1500,6 +1798,21 @@ function renderMcpServers(servers) {
1500
1798
  ];
1501
1799
  })));
1502
1800
  }
1801
+ function renderSubagents(subagents) {
1802
+ const table = renderTable([
1803
+ "Subagent",
1804
+ "Level",
1805
+ "When to use"
1806
+ ], subagents.map((subagent) => {
1807
+ const origin = describeOrigin(subagent);
1808
+ return [
1809
+ subagent.artifact.key,
1810
+ origin,
1811
+ subagent.artifact.description
1812
+ ];
1813
+ }));
1814
+ return renderSection("Subagents", SUBAGENTS_INTRO, table);
1815
+ }
1503
1816
  function renderEnv(env) {
1504
1817
  const table = renderTable(["Variable", "Level"], env.map((variable) => {
1505
1818
  const origin = describeOrigin(variable);
@@ -1532,7 +1845,7 @@ function renderSection(title, intro, table) {
1532
1845
  //#region src/generate/agents-index.ts
1533
1846
  /**
1534
1847
  * AGENTS.md: an index of the setup, not the rules themselves. It lists the levels, every
1535
- * rule, skill, command and MCP server, where each comes from and where its file is.
1848
+ * rule, skill, command, MCP server and subagent, where each comes from and where its file is.
1536
1849
  */
1537
1850
  function renderAgentsIndex(resolution) {
1538
1851
  const title = `# AI agent context: ${resolution.chain.project.name}`;
@@ -1546,6 +1859,7 @@ function renderAgentsIndex(resolution) {
1546
1859
  const hasSkills = resolution.skills.length > 0;
1547
1860
  const hasCommands = resolution.commands.length > 0;
1548
1861
  const hasMcpServers = resolution.mcp.length > 0;
1862
+ const hasSubagents = resolution.subagents.length > 0;
1549
1863
  const hasEnv = resolution.env.length > 0;
1550
1864
  if (hasRules) {
1551
1865
  const rules = renderRules(resolution.rules);
@@ -1563,6 +1877,10 @@ function renderAgentsIndex(resolution) {
1563
1877
  const servers = renderMcpServers(resolution.mcp);
1564
1878
  sections.push(servers);
1565
1879
  }
1880
+ if (hasSubagents) {
1881
+ const subagents = renderSubagents(resolution.subagents);
1882
+ sections.push(subagents);
1883
+ }
1566
1884
  if (hasEnv) {
1567
1885
  const env = renderEnv(resolution.env);
1568
1886
  sections.push(env);
@@ -1652,6 +1970,7 @@ const ArtifactKind = {
1652
1970
  Skills: "skills",
1653
1971
  Commands: "commands",
1654
1972
  Mcp: "mcp",
1973
+ Subagents: "subagents",
1655
1974
  Env: "env"
1656
1975
  };
1657
1976
  //#endregion
@@ -1722,6 +2041,62 @@ function orderFromSpecific(levels, inherited, selectArtifacts) {
1722
2041
  return ordered;
1723
2042
  }
1724
2043
  //#endregion
2044
+ //#region src/inheritance/inherit-permissions.ts
2045
+ /**
2046
+ * Permission lists are joined along the chain: every level adds its rules. A more specific
2047
+ * level can remove inherited `allow` and `ask` rules with `exclude.permissions`, but not
2048
+ * `deny` rules, so that a general level keeps a floor of what is never allowed.
2049
+ */
2050
+ function inheritPermissions(levels) {
2051
+ const inherited = /* @__PURE__ */ new Map();
2052
+ const problems = [];
2053
+ for (const level of levels) {
2054
+ const excludeProblems = excludeRules(inherited, level);
2055
+ const origin = createOrigin(level);
2056
+ problems.push(...excludeProblems);
2057
+ for (const rule of level.permissions) {
2058
+ const id = buildRuleId(rule);
2059
+ const previous = inherited.get(id);
2060
+ const overrides = previous === void 0 ? [] : [...previous.overrides, previous.origin];
2061
+ inherited.set(id, {
2062
+ artifact: rule,
2063
+ origin,
2064
+ overrides,
2065
+ patchedBy: []
2066
+ });
2067
+ }
2068
+ }
2069
+ return {
2070
+ artifacts: [...inherited.values()],
2071
+ problems
2072
+ };
2073
+ }
2074
+ /** The same rule can be in two lists, for example in `allow` on one level and `deny` on another. */
2075
+ function buildRuleId(rule) {
2076
+ return `${rule.decision} ${rule.key}`;
2077
+ }
2078
+ function excludeRules(inherited, level) {
2079
+ const keys = level.exclude.permissions ?? [];
2080
+ const problems = [];
2081
+ for (const key of keys) {
2082
+ const matches = [...inherited.values()].filter((resolved) => resolved.artifact.key === key);
2083
+ const deny = matches.find((resolved) => resolved.artifact.decision === PermissionDecision.Deny);
2084
+ if (typeof deny === "object") {
2085
+ problems.push(`${level.name}: exclude.permissions lists "${key}", but it is a deny rule of ${deny.origin.name}, and a deny rule cannot be removed`);
2086
+ continue;
2087
+ }
2088
+ if (matches.length === 0) {
2089
+ problems.push(`${level.name}: exclude.permissions lists "${key}", but no more general level has it`);
2090
+ continue;
2091
+ }
2092
+ for (const match of matches) {
2093
+ const id = buildRuleId(match.artifact);
2094
+ inherited.delete(id);
2095
+ }
2096
+ }
2097
+ return problems;
2098
+ }
2099
+ //#endregion
1725
2100
  //#region src/shared/utils/records.ts
1726
2101
  /** Merges two string maps; keys of `patch` win. Missing on both sides stays missing. */
1727
2102
  function mergeRecords(base, patch) {
@@ -1790,13 +2165,17 @@ function applyInheritance(chain) {
1790
2165
  const skills = inheritArtifacts(chain.levels, ArtifactKind.Skills, (level) => level.skills);
1791
2166
  const commands = inheritArtifacts(chain.levels, ArtifactKind.Commands, (level) => level.commands);
1792
2167
  const mcp = inheritArtifacts(chain.levels, ArtifactKind.Mcp, (level) => level.mcp, patchMcpServers);
2168
+ const subagents = inheritArtifacts(chain.levels, ArtifactKind.Subagents, (level) => level.subagents);
1793
2169
  const env = inheritArtifacts(chain.levels, ArtifactKind.Env, (level) => level.env);
2170
+ const permissions = inheritPermissions(chain.levels);
1794
2171
  const problems = [
1795
2172
  ...rules.problems,
1796
2173
  ...skills.problems,
1797
2174
  ...commands.problems,
1798
2175
  ...mcp.problems,
1799
- ...env.problems
2176
+ ...subagents.problems,
2177
+ ...env.problems,
2178
+ ...permissions.problems
1800
2179
  ];
1801
2180
  if (problems.length > 0) throw createProblemsError(problems);
1802
2181
  return {
@@ -1805,7 +2184,9 @@ function applyInheritance(chain) {
1805
2184
  skills: skills.artifacts,
1806
2185
  commands: commands.artifacts,
1807
2186
  mcp: mcp.artifacts,
1808
- env: env.artifacts
2187
+ subagents: subagents.artifacts,
2188
+ env: env.artifacts,
2189
+ permissions: permissions.artifacts
1809
2190
  };
1810
2191
  }
1811
2192
  //#endregion
@@ -2157,14 +2538,29 @@ async function runSetup(projectDir, options) {
2157
2538
  /** For --verbose: every artifact, the level it comes from and the file to edit. */
2158
2539
  function describeResolution(resolution) {
2159
2540
  const projectDir = resolution.chain.project.dir;
2541
+ const permissions = resolution.permissions.map(labelPermission);
2160
2542
  return [
2161
2543
  describeSection("Rules", resolution.rules, projectDir),
2162
2544
  describeSection("Skills", resolution.skills, projectDir),
2163
2545
  describeSection("Commands", resolution.commands, projectDir),
2164
2546
  describeSection("MCP servers", resolution.mcp, projectDir),
2165
- describeSection("Environment", resolution.env, projectDir)
2547
+ describeSection("Subagents", resolution.subagents, projectDir),
2548
+ describeSection("Environment", resolution.env, projectDir),
2549
+ describeSection("Permissions", permissions, projectDir)
2166
2550
  ].filter((section) => section.length > 0).join("\n\n");
2167
2551
  }
2552
+ /** For example `deny Read(./.env)`: the same rule can be in two lists. */
2553
+ function labelPermission(resolved) {
2554
+ const { decision, key } = resolved.artifact;
2555
+ const artifact = {
2556
+ ...resolved.artifact,
2557
+ key: `${decision} ${key}`
2558
+ };
2559
+ return {
2560
+ ...resolved,
2561
+ artifact
2562
+ };
2563
+ }
2168
2564
  function describeSection(title, artifacts, projectDir) {
2169
2565
  if (artifacts.length === 0) return "";
2170
2566
  const rows = artifacts.map((resolved) => {
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- export { t as VERSION } from "./src-BNeh64fK.mjs";
1
+ export { t as VERSION } from "./src-CZK2Ixbt.mjs";
@@ -1,5 +1,5 @@
1
1
  //#endregion
2
2
  //#region src/index.ts
3
- const VERSION = "0.3.0";
3
+ const VERSION = "0.5.0";
4
4
  //#endregion
5
5
  export { VERSION as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunestra",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Tune your AI agents to play as one orchestra: one source of rules, skills, commands and MCP servers for Claude Code, Codex and more.",
5
5
  "keywords": [
6
6
  "ai",