tunestra 0.4.0 → 0.6.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/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as VERSION } from "./src-Bh7d7ERC.mjs";
2
+ import { t as VERSION } from "./src-DTrYA2yZ.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";
@@ -7,6 +7,7 @@ import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } fr
7
7
  import { glob } from "tinyglobby";
8
8
  import { prettifyError, z } from "zod";
9
9
  import { parse, stringify } from "yaml";
10
+ import { homedir } from "node:os";
10
11
  import { downloadTemplate } from "giget";
11
12
  import { stringify as stringify$1 } from "smol-toml";
12
13
  import { createHash } from "node:crypto";
@@ -17,6 +18,10 @@ const PRESET_MANIFEST = "tunestra.preset.yaml";
17
18
  const PROJECT_CONFIG = "tunestra.yaml";
18
19
  /** Project artifacts: the same layout as a preset. */
19
20
  const PROJECT_DIR = ".tunestra";
21
+ /** The personal level of a developer; it is not committed. */
22
+ const LOCAL_CONFIG = "tunestra.local.yaml";
23
+ /** Artifacts of the personal level: the same layout as a preset. */
24
+ const LOCAL_DIR = ".tunestra.local";
20
25
  /** Main instructions of a level, and the index that tunestra generates. */
21
26
  const INSTRUCTIONS_FILE = "AGENTS.md";
22
27
  //#endregion
@@ -168,6 +173,7 @@ const Agent = {
168
173
  //#endregion
169
174
  //#region src/init/render-config.ts
170
175
  const EXTENDS_EXAMPLE = "# - github:<owner>/<repo>/<path>#<ref>";
176
+ const LOCAL_EXTENDS_EXAMPLE = "# - ~/presets/me";
171
177
  /** The first tunestra.yaml of a project, with a hint when there are no presets yet. */
172
178
  function renderConfig(presets) {
173
179
  const hasPresets = presets.length > 0;
@@ -181,6 +187,23 @@ function renderConfig(presets) {
181
187
  ""
182
188
  ].join("\n");
183
189
  }
190
+ /** The first tunestra.local.yaml, with commented examples of what a personal level can do. */
191
+ function renderLocalConfig(presets) {
192
+ const hasPresets = presets.length > 0;
193
+ const presetLines = presets.map((preset) => ` - ${JSON.stringify(preset)}`);
194
+ return [
195
+ "# Your personal level of tunestra: only for you, never committed.",
196
+ "# Personal presets with level: local, from general to specific:",
197
+ ...hasPresets ? ["extends:", ...presetLines] : ["extends: []", LOCAL_EXTENDS_EXAMPLE],
198
+ "# exclude:",
199
+ "# skills: [<skill>]",
200
+ "# env:",
201
+ "# HTTPS_PROXY: http://localhost:3128",
202
+ "# permissions:",
203
+ "# allow: [Bash(make *)]",
204
+ ""
205
+ ].join("\n");
206
+ }
184
207
  //#endregion
185
208
  //#region src/init/run-init.ts
186
209
  const CLAUDE_FILE$1 = "CLAUDE.md";
@@ -251,30 +274,70 @@ async function moveFile(projectDir, from, to) {
251
274
  await rename(fromPath, toPath);
252
275
  }
253
276
  //#endregion
277
+ //#region src/init/run-init-local.ts
278
+ const LOCAL_RULES_DIR = `${LOCAL_DIR}/rules`;
279
+ /**
280
+ * Creates the personal level in a project that already uses tunestra: tunestra.local.yaml
281
+ * and `.tunestra.local/rules/` for the first personal rule.
282
+ */
283
+ async function runInitLocal(projectDir, options) {
284
+ const configPath = join(projectDir, PROJECT_CONFIG);
285
+ const localPath = join(projectDir, LOCAL_CONFIG);
286
+ const rulesDir = join(projectDir, LOCAL_RULES_DIR);
287
+ const hasConfig = await checkExists(configPath);
288
+ const hasLocal = await checkExists(localPath);
289
+ const problems = [];
290
+ if (hasConfig === false) problems.push(`${PROJECT_CONFIG} not found. Run \`tunestra init\` first`);
291
+ if (hasLocal) problems.push(`${LOCAL_CONFIG} already exists`);
292
+ if (problems.length > 0) throw createProblemsError(problems);
293
+ const config = renderLocalConfig(options.presets);
294
+ await writeFile(localPath, config);
295
+ await mkdir(rulesDir, { recursive: true });
296
+ return {
297
+ steps: [`Created ${LOCAL_CONFIG}`, `Created ${LOCAL_RULES_DIR}/`],
298
+ warnings: []
299
+ };
300
+ }
301
+ //#endregion
254
302
  //#region src/cli/commands/init.ts
255
303
  async function describeInit(projectDir, options) {
256
- const report = await runInit(projectDir, options);
257
- const check = pc.green("✔");
258
- const warningIcon = pc.yellow("⚠");
259
- const steps = report.steps.map((step) => `${check} ${step}`);
260
- const warnings = report.warnings.map((warning) => `${warningIcon} ${warning}`);
261
- const nextStep = options.presets.length > 0 ? "Next: run tunestra setup." : "Next: add presets to extends in tunestra.yaml, then run tunestra setup.";
304
+ const report = options.local ? await runInitLocal(projectDir, options) : await runInit(projectDir, options);
305
+ const nextStep = describeNextStep(options);
262
306
  return [
263
- ...steps,
264
- ...warnings,
307
+ ...describeReport(report),
265
308
  "",
266
309
  nextStep
267
310
  ].join("\n");
268
311
  }
312
+ function describeReport(report) {
313
+ const check = pc.green("✔");
314
+ const warningIcon = pc.yellow("⚠");
315
+ const steps = report.steps.map((step) => `${check} ${step}`);
316
+ const warnings = report.warnings.map((warning) => `${warningIcon} ${warning}`);
317
+ return [...steps, ...warnings];
318
+ }
319
+ function describeNextStep(options) {
320
+ const hasPresets = options.presets.length > 0;
321
+ if (options.local) return "Next: add your rules to .tunestra.local/ or presets to tunestra.local.yaml, then run tunestra setup. setup puts both in .gitignore.";
322
+ if (hasPresets) return "Next: run tunestra setup.";
323
+ return "Next: add presets to extends in tunestra.yaml, then run tunestra setup.";
324
+ }
269
325
  //#endregion
270
326
  //#region src/shared/constants/levels.ts
271
- /** Levels of a shared preset, from specific to general. */
327
+ /**
328
+ * Levels of a preset, from specific to general. A `local` preset is personal: only the
329
+ * personal level of a developer can extend it.
330
+ */
272
331
  const PresetLevel = {
332
+ Local: "local",
273
333
  Team: "team",
274
334
  Domain: "domain",
275
335
  Base: "base"
276
336
  };
277
- /** Every level of the chain; the project is the most specific one. */
337
+ /**
338
+ * Every level of the chain. The project is the most specific shared level; the personal
339
+ * `local` level of a developer comes after it.
340
+ */
278
341
  const Level = {
279
342
  Project: "project",
280
343
  ...PresetLevel
@@ -284,6 +347,7 @@ const Level = {
284
347
  * so that agents read specific rules first: `10-project-architecture.md`.
285
348
  */
286
349
  const LEVEL_RANK = {
350
+ [Level.Local]: 0,
287
351
  [Level.Project]: 10,
288
352
  [Level.Team]: 20,
289
353
  [Level.Domain]: 30,
@@ -350,6 +414,7 @@ const excludeSchema = z.strictObject({
350
414
  skills: keys.optional(),
351
415
  commands: keys.optional(),
352
416
  mcp: keys.optional(),
417
+ subagents: keys.optional(),
353
418
  env: keys.optional(),
354
419
  /** Inherited `allow` and `ask` rules; a `deny` rule cannot be removed. */
355
420
  permissions: keys.optional()
@@ -381,6 +446,15 @@ const projectConfigSchema = z.strictObject({
381
446
  env: envSchema.default({}),
382
447
  permissions: permissionsSchema.default({})
383
448
  });
449
+ /** `tunestra.local.yaml`: a level like the project, without the list of agents. */
450
+ const localConfigSchema = z.strictObject({
451
+ name: z.string().min(1).default("local"),
452
+ extends: z.array(z.string().min(1)).readonly().default([]),
453
+ exclude: excludeSchema.default({}),
454
+ overrides: overridesSchema.default({}),
455
+ env: envSchema.default({}),
456
+ permissions: permissionsSchema.default({})
457
+ });
384
458
  const mcpServerSchema = z.strictObject({
385
459
  description: z.string().optional(),
386
460
  type: z.enum([
@@ -463,6 +537,15 @@ async function mapSettled(items, map) {
463
537
  throw buildResultsError(results);
464
538
  }
465
539
  //#endregion
540
+ //#region src/shared/constants/subagents.ts
541
+ /** Reasoning effort of a subagent: the levels that both Claude Code and Codex accept. */
542
+ const SubagentEffort = {
543
+ Low: "low",
544
+ Medium: "medium",
545
+ High: "high",
546
+ ExtraHigh: "xhigh"
547
+ };
548
+ //#endregion
466
549
  //#region src/shared/schemas/frontmatter.ts
467
550
  const ruleFrontmatterSchema = z.looseObject({ description: z.string().optional() });
468
551
  const skillFrontmatterSchema = z.looseObject({
@@ -470,6 +553,15 @@ const skillFrontmatterSchema = z.looseObject({
470
553
  description: z.string().min(1)
471
554
  });
472
555
  const commandFrontmatterSchema = z.looseObject({ description: z.string().optional() });
556
+ /** The model names differ between agents, so a subagent sets one for each agent. */
557
+ const subagentModelSchema = z.partialRecord(z.enum(Agent), z.string().min(1));
558
+ /** tunestra writes the files of the agents itself, so it knows every field here. */
559
+ const subagentFrontmatterSchema = z.strictObject({
560
+ description: z.string().min(1),
561
+ model: subagentModelSchema.default({}),
562
+ effort: z.enum(SubagentEffort).optional(),
563
+ readOnly: z.boolean().default(false)
564
+ });
473
565
  //#endregion
474
566
  //#region src/presets/frontmatter.ts
475
567
  /**
@@ -602,6 +694,28 @@ function findDuplicateSkills(skills, dir) {
602
694
  return problems;
603
695
  }
604
696
  //#endregion
697
+ //#region src/presets/artifacts/subagents.ts
698
+ /** Subagents live in `agents/`, like in Claude Code and Codex. */
699
+ async function loadSubagents(dir) {
700
+ return mapSettled(await listFiles(dir, "agents/*.md"), (source) => loadSubagent(dir, source));
701
+ }
702
+ async function loadSubagent(dir, source) {
703
+ const file = join(dir, source);
704
+ const { data, body } = parseFrontmatter(await readFile(file, "utf8"), file);
705
+ const frontmatter = validate(data, subagentFrontmatterSchema, `frontmatter in ${file}`);
706
+ const key = validate(basename(source, ".md"), skillName, `subagent file ${source}`);
707
+ if (body.trim().length > 0 === false) throw new TunestraError(`${file} has no instructions: write them after the frontmatter`);
708
+ return {
709
+ key,
710
+ source,
711
+ description: frontmatter.description,
712
+ model: frontmatter.model,
713
+ effort: frontmatter.effort,
714
+ readOnly: frontmatter.readOnly,
715
+ body
716
+ };
717
+ }
718
+ //#endregion
605
719
  //#region src/presets/artifacts/load-artifacts.ts
606
720
  /** Loads every kind of artifact at the same time and reports the problems of all of them. */
607
721
  async function loadArtifacts(dir, presetName) {
@@ -609,19 +723,22 @@ async function loadArtifacts(dir, presetName) {
609
723
  const skillsLoading = loadSkills(dir);
610
724
  const commandsLoading = loadCommands(dir);
611
725
  const mcpLoading = loadMcpServers(dir);
726
+ const subagentsLoading = loadSubagents(dir);
612
727
  const results = await Promise.allSettled([
613
728
  rulesLoading,
614
729
  skillsLoading,
615
730
  commandsLoading,
616
- mcpLoading
731
+ mcpLoading,
732
+ subagentsLoading
617
733
  ]);
618
734
  if (isEveryFulfilled(results) === false) throw buildResultsError(results);
619
- const [rulesResult, skillsResult, commandsResult, mcpResult] = results;
735
+ const [rulesResult, skillsResult, commandsResult, mcpResult, subagentsResult] = results;
620
736
  return {
621
737
  rules: rulesResult.value,
622
738
  skills: skillsResult.value,
623
739
  commands: commandsResult.value,
624
- mcp: mcpResult.value
740
+ mcp: mcpResult.value,
741
+ subagents: subagentsResult.value
625
742
  };
626
743
  }
627
744
  //#endregion
@@ -657,12 +774,12 @@ const PermissionDecision = {
657
774
  };
658
775
  //#endregion
659
776
  //#region src/presets/permissions.ts
660
- const DECISIONS$1 = Object.values(PermissionDecision);
777
+ const DECISIONS$2 = Object.values(PermissionDecision);
661
778
  /** The `permissions` lists of a manifest as one list of rules, in the order of the file. */
662
779
  function createPermissionRules(permissions, manifestPath, artifactsDir) {
663
780
  const source = relativePosix(artifactsDir, manifestPath);
664
781
  const rules = [];
665
- for (const decision of DECISIONS$1) {
782
+ for (const decision of DECISIONS$2) {
666
783
  const keys = permissions[decision] ?? [];
667
784
  for (const key of keys) rules.push({
668
785
  key,
@@ -764,6 +881,7 @@ function createDownloadOptions(dir, token) {
764
881
  }
765
882
  //#endregion
766
883
  //#region src/sources/create-source-resolver.ts
884
+ const HOME_PREFIX = "~/";
767
885
  function createSourceResolver(projectDir, download = downloadRepository) {
768
886
  const downloads = /* @__PURE__ */ new Map();
769
887
  const repositories = [];
@@ -793,7 +911,7 @@ function createSourceResolver(projectDir, download = downloadRepository) {
793
911
  const repository = await fetchRepository(gitSource);
794
912
  return checkInside(repository, join(repository.root, gitSource.path), ref);
795
913
  }
796
- const dir = resolve(fromDir, ref);
914
+ const dir = resolveLocalPath(ref, fromDir);
797
915
  const repository = findRepository(fromDir);
798
916
  if (repository === void 0) return dir;
799
917
  return checkInside(repository, dir, ref);
@@ -823,6 +941,17 @@ function isInside(root, dir) {
823
941
  const isOtherDrive = isAbsolute(path);
824
942
  return isParent === false && isOtherDrive === false;
825
943
  }
944
+ /**
945
+ * A path from `extends`. `~/` starts at the home directory, so that a personal preset can
946
+ * live there; any other path starts at the directory that refers to it.
947
+ */
948
+ function resolveLocalPath(ref, fromDir) {
949
+ if (ref.startsWith(HOME_PREFIX)) {
950
+ const path = ref.slice(2);
951
+ return resolve(homedir(), path);
952
+ }
953
+ return resolve(fromDir, ref);
954
+ }
826
955
  //#endregion
827
956
  //#region src/chain/check-chain.ts
828
957
  /** A level can extend the same or a more general level, never a more specific one. */
@@ -854,7 +983,7 @@ function findDuplicateNames(levels) {
854
983
  //#endregion
855
984
  //#region src/chain/linearize-chain.ts
856
985
  /**
857
- * Puts the levels in order with a depth-first walk from the project: parents first, in the
986
+ * Puts the levels in order with a depth-first walk from the root: parents first, in the
858
987
  * order of `extends`, then the level itself. A level that is reached twice is added once.
859
988
  */
860
989
  function linearizeChain(graph) {
@@ -876,7 +1005,7 @@ function linearizeChain(graph) {
876
1005
  if (level === void 0) return;
877
1006
  levels.push(level);
878
1007
  };
879
- visit(graph.project.dir, []);
1008
+ visit(graph.root.dir, []);
880
1009
  return {
881
1010
  levels,
882
1011
  problems: [...problems]
@@ -916,14 +1045,14 @@ async function loadPreset(dir) {
916
1045
  //#endregion
917
1046
  //#region src/chain/load-preset-graph.ts
918
1047
  /**
919
- * Loads the presets of the project, then the presets they extend, and so on. Each step loads
1048
+ * Loads the presets of the root, then the presets they extend, and so on. Each step loads
920
1049
  * its presets in parallel; a preset that several levels extend is loaded once.
921
1050
  */
922
- async function loadPresetGraph(project, resolver) {
1051
+ async function loadPresetGraph(root, resolver) {
923
1052
  const levels = /* @__PURE__ */ new Map();
924
1053
  const parents = /* @__PURE__ */ new Map();
925
- let pending = [project];
926
- levels.set(project.dir, project);
1054
+ let pending = [root];
1055
+ levels.set(root.dir, root);
927
1056
  while (pending.length > 0) {
928
1057
  const dirsToLoad = /* @__PURE__ */ new Set();
929
1058
  for (const preset of pending) {
@@ -935,7 +1064,7 @@ async function loadPresetGraph(project, resolver) {
935
1064
  for (const preset of pending) levels.set(preset.dir, preset);
936
1065
  }
937
1066
  return {
938
- project,
1067
+ root,
939
1068
  levels,
940
1069
  parents
941
1070
  };
@@ -969,6 +1098,67 @@ async function loadChain(projectDir, options = {}) {
969
1098
  };
970
1099
  }
971
1100
  //#endregion
1101
+ //#region src/presets/load-local.ts
1102
+ /** The personal level of the developer, when the project has `tunestra.local.yaml`. */
1103
+ async function loadLocal(projectDir) {
1104
+ const root = resolve(projectDir);
1105
+ const configPath = join(root, LOCAL_CONFIG);
1106
+ if (await checkExists(configPath) === false) return;
1107
+ const config = await readYaml(configPath, localConfigSchema, "personal config");
1108
+ const artifactsDir = join(root, LOCAL_DIR);
1109
+ const artifacts = await loadArtifacts(artifactsDir, config.name);
1110
+ const env = createEnvVariables(config.env, configPath, artifactsDir);
1111
+ const permissions = createPermissionRules(config.permissions, configPath, artifactsDir);
1112
+ return {
1113
+ ...config,
1114
+ level: Level.Local,
1115
+ description: void 0,
1116
+ dir: root,
1117
+ artifactsDir,
1118
+ remote: void 0,
1119
+ ...artifacts,
1120
+ env,
1121
+ permissions
1122
+ };
1123
+ }
1124
+ //#endregion
1125
+ //#region src/chain/load-local-chain.ts
1126
+ /**
1127
+ * The shared chain with the personal level of the developer and its presets on top, or
1128
+ * `undefined` when the project has no `tunestra.local.yaml`.
1129
+ */
1130
+ async function loadLocalChain(shared, options = {}) {
1131
+ const local = await loadLocal(shared.project.dir);
1132
+ if (local === void 0) return;
1133
+ const linearization = linearizeChain(await loadPresetGraph(local, createSourceResolver(shared.project.dir, options.download)));
1134
+ const levels = [...shared.levels, ...linearization.levels];
1135
+ const levelProblems = linearization.levels.flatMap(findLocalProblems);
1136
+ const nameProblems = findDuplicateNames(levels);
1137
+ const problems = [
1138
+ ...linearization.problems,
1139
+ ...levelProblems,
1140
+ ...nameProblems
1141
+ ];
1142
+ if (problems.length > 0) throw createProblemsError(problems);
1143
+ return {
1144
+ project: shared.project,
1145
+ levels
1146
+ };
1147
+ }
1148
+ /**
1149
+ * A personal level changes only the files that are not committed. Claude Code joins
1150
+ * `.claude/settings.local.json` with the committed `.claude/settings.json`, so a personal
1151
+ * level can add env and permissions, but cannot remove them.
1152
+ */
1153
+ function findLocalProblems(level) {
1154
+ const problems = [];
1155
+ if (level.level !== Level.Local) problems.push(`${level.name} (${level.level}) is extended by the personal level. A personal level can only extend presets with level: local`);
1156
+ const excludesEnv = (level.exclude.env?.length ?? 0) > 0;
1157
+ const excludesPermissions = (level.exclude.permissions?.length ?? 0) > 0;
1158
+ if (excludesEnv || excludesPermissions) problems.push(`${level.name}: a personal level cannot exclude env or permissions, because Claude Code joins .claude/settings.local.json with the committed .claude/settings.json`);
1159
+ return problems;
1160
+ }
1161
+ //#endregion
972
1162
  //#region src/generate/describe-source.ts
973
1163
  /** Files built from the whole chain, like AGENTS.md: there is no single file to edit. */
974
1164
  const SETUP_SOURCE = "tunestra.yaml and its presets";
@@ -981,6 +1171,139 @@ function describeArtifactSource(projectDir, origin, source) {
981
1171
  return relativePosix(projectDir, join(origin.artifactsDir, source));
982
1172
  }
983
1173
  //#endregion
1174
+ //#region src/shared/utils/markdown.ts
1175
+ /**
1176
+ * A pipe or a line break inside a cell would break the table.
1177
+ *
1178
+ * Matches: "|" in "a | b", "\n" and "\r\n" in "line one\nline two"
1179
+ */
1180
+ const TABLE_BREAKING_PATTERN = /\||\r?\n/g;
1181
+ function renderTable(header, rows) {
1182
+ return [
1183
+ renderRow(header),
1184
+ `|${header.map(() => "---").join("|")}|`,
1185
+ ...rows.map(renderRow)
1186
+ ].join("\n");
1187
+ }
1188
+ function renderRow(cells) {
1189
+ return `| ${cells.map(escapeCell).join(" | ")} |`;
1190
+ }
1191
+ function escapeCell(cell) {
1192
+ return cell.replace(TABLE_BREAKING_PATTERN, replaceTableBreaking);
1193
+ }
1194
+ function replaceTableBreaking(match) {
1195
+ if (match === "|") return "\\|";
1196
+ return " ";
1197
+ }
1198
+ //#endregion
1199
+ //#region src/generate/describe-origin.ts
1200
+ /** For example `team, overrides base` or `base, patched by team`. */
1201
+ function describeOrigin(resolved) {
1202
+ const parts = [resolved.origin.level];
1203
+ const hasOverrides = resolved.overrides.length > 0;
1204
+ const isPatched = resolved.patchedBy.length > 0;
1205
+ if (hasOverrides) {
1206
+ const names = joinNames(resolved.overrides);
1207
+ parts.push(`overrides ${names}`);
1208
+ }
1209
+ if (isPatched) {
1210
+ const names = joinNames(resolved.patchedBy);
1211
+ parts.push(`patched by ${names}`);
1212
+ }
1213
+ return parts.join(", ");
1214
+ }
1215
+ function joinNames(origins) {
1216
+ return origins.map((origin) => origin.name).join(", ");
1217
+ }
1218
+ //#endregion
1219
+ //#region src/generate/local-changes.ts
1220
+ const INTRO = `The personal level of this developer (\`${LOCAL_CONFIG}\`) changes the setup in AGENTS.md on this machine only. When this file and AGENTS.md disagree, follow this file.`;
1221
+ const TURNED_OFF_INTRO = "These artifacts of AGENTS.md are turned off on this machine.";
1222
+ const NO_CHANGES = "The personal level changes nothing yet.";
1223
+ const CHANGING = `Edit \`${LOCAL_CONFIG}\` or \`${LOCAL_DIR}/\` and run \`tunestra setup\`.`;
1224
+ /**
1225
+ * What the personal level adds, overrides and turns off, compared with the committed
1226
+ * AGENTS.md. Claude Code reads it after CLAUDE.md as `CLAUDE.local.md`.
1227
+ */
1228
+ function renderLocalChanges(shared, local) {
1229
+ const groups = listGroups(shared, local);
1230
+ const changedRows = groups.flatMap(listChangedRows);
1231
+ const turnedOffRows = groups.flatMap(listTurnedOffRows);
1232
+ const sections = [
1233
+ GENERATED_NOTICE,
1234
+ "# Personal setup",
1235
+ INTRO
1236
+ ];
1237
+ if (changedRows.length > 0) {
1238
+ const table = renderTable([
1239
+ "Kind",
1240
+ "Name",
1241
+ "Level",
1242
+ "Description"
1243
+ ], changedRows);
1244
+ sections.push("## Added and changed", table);
1245
+ }
1246
+ if (turnedOffRows.length > 0) {
1247
+ const table = renderTable(["Kind", "Name"], turnedOffRows);
1248
+ sections.push("## Turned off", TURNED_OFF_INTRO, table);
1249
+ }
1250
+ if ((changedRows.length > 0 || turnedOffRows.length > 0) === false) sections.push(NO_CHANGES);
1251
+ sections.push(CHANGING);
1252
+ return `${sections.join("\n\n")}\n`;
1253
+ }
1254
+ function listGroups(shared, local) {
1255
+ return [
1256
+ createGroup("Rule", shared.rules, local.rules),
1257
+ createGroup("Skill", shared.skills, local.skills),
1258
+ createGroup("Command", shared.commands.map(labelCommand), local.commands.map(labelCommand)),
1259
+ createGroup("MCP server", shared.mcp, local.mcp),
1260
+ createGroup("Subagent", shared.subagents, local.subagents),
1261
+ createGroup("Variable", shared.env, local.env),
1262
+ createGroup("Permission", shared.permissions.map(labelPermission$1), local.permissions.map(labelPermission$1))
1263
+ ];
1264
+ }
1265
+ function createGroup(kind, shared, local) {
1266
+ return {
1267
+ kind,
1268
+ shared,
1269
+ local
1270
+ };
1271
+ }
1272
+ function listChangedRows(group) {
1273
+ return group.local.filter((resolved) => resolved.origin.level === Level.Local).map((resolved) => {
1274
+ const origin = describeOrigin(resolved);
1275
+ const description = resolved.artifact.description ?? "";
1276
+ return [
1277
+ group.kind,
1278
+ resolved.artifact.key,
1279
+ origin,
1280
+ description
1281
+ ];
1282
+ });
1283
+ }
1284
+ function listTurnedOffRows(group) {
1285
+ const localKeys = new Set(group.local.map((resolved) => resolved.artifact.key));
1286
+ return group.shared.filter((resolved) => localKeys.has(resolved.artifact.key) === false).map((resolved) => [group.kind, resolved.artifact.key]);
1287
+ }
1288
+ function labelCommand(resolved) {
1289
+ const artifact = {
1290
+ key: `/${resolved.artifact.key}`,
1291
+ description: resolved.artifact.description
1292
+ };
1293
+ return {
1294
+ ...resolved,
1295
+ artifact
1296
+ };
1297
+ }
1298
+ /** For example `deny Read(./.env)`: the same rule can be in two lists. */
1299
+ function labelPermission$1(resolved) {
1300
+ const artifact = { key: `${resolved.artifact.decision} ${resolved.artifact.key}` };
1301
+ return {
1302
+ ...resolved,
1303
+ artifact
1304
+ };
1305
+ }
1306
+ //#endregion
984
1307
  //#region src/shared/utils/outputs.ts
985
1308
  function createFileOutput(path, content, source) {
986
1309
  return {
@@ -1096,7 +1419,7 @@ function compareByName([a], [b]) {
1096
1419
  //#endregion
1097
1420
  //#region src/agents/claude/settings-json.ts
1098
1421
  const SETTINGS_SCHEMA = "https://json.schemastore.org/claude-code-settings.json";
1099
- const DECISIONS = Object.values(PermissionDecision);
1422
+ const DECISIONS$1 = Object.values(PermissionDecision);
1100
1423
  /** The keys that tunestra writes; a file with only these keys is a generated one. */
1101
1424
  const SETTINGS_KEYS = [
1102
1425
  "$schema",
@@ -1112,10 +1435,23 @@ function renderSettingsJson(settings) {
1112
1435
  if (hasPermissions) content.permissions = buildPermissionLists(settings.permissions);
1113
1436
  return `${JSON.stringify(content, null, 2)}\n`;
1114
1437
  }
1438
+ /** Every list, empty ones included, for entries that setup adds to a file of people. */
1439
+ function buildSettingsEntries(env, permissions) {
1440
+ const envMap = buildEnvMap(env);
1441
+ const lists = buildPermissionLists(permissions);
1442
+ return {
1443
+ env: envMap,
1444
+ permissions: {
1445
+ [PermissionDecision.Allow]: lists[PermissionDecision.Allow] ?? [],
1446
+ [PermissionDecision.Ask]: lists[PermissionDecision.Ask] ?? [],
1447
+ [PermissionDecision.Deny]: lists[PermissionDecision.Deny] ?? []
1448
+ }
1449
+ };
1450
+ }
1115
1451
  /** Only the lists that have rules; a rule repeated on several levels is written once. */
1116
1452
  function buildPermissionLists(permissions) {
1117
1453
  const lists = {};
1118
- for (const decision of DECISIONS) {
1454
+ for (const decision of DECISIONS$1) {
1119
1455
  const keys = permissions.filter((resolved) => resolved.artifact.decision === decision).map((resolved) => resolved.artifact.key);
1120
1456
  const sortedKeys = [...new Set(keys)].toSorted();
1121
1457
  if (sortedKeys.length > 0) lists[decision] = sortedKeys;
@@ -1123,16 +1459,40 @@ function buildPermissionLists(permissions) {
1123
1459
  return lists;
1124
1460
  }
1125
1461
  //#endregion
1462
+ //#region src/agents/claude/subagent-md.ts
1463
+ /** Tools that change files; a read-only subagent keeps the tools that read and search. */
1464
+ const WRITING_TOOLS = [
1465
+ "Write",
1466
+ "Edit",
1467
+ "NotebookEdit",
1468
+ "Bash"
1469
+ ];
1470
+ /** `.claude/agents/<name>.md`: the frontmatter of Claude Code and the instructions. */
1471
+ function renderSubagentMd(subagent) {
1472
+ const { key, description, model, effort, readOnly, body } = subagent;
1473
+ const frontmatter = {
1474
+ name: key,
1475
+ description
1476
+ };
1477
+ if (typeof model.claude === "string") frontmatter.model = model.claude;
1478
+ if (typeof effort === "string") frontmatter.effort = effort;
1479
+ if (readOnly) frontmatter.disallowedTools = WRITING_TOOLS;
1480
+ return `---\n${stringify(frontmatter)}---\n${body}`;
1481
+ }
1482
+ //#endregion
1126
1483
  //#region src/agents/claude/claude-adapter.ts
1127
1484
  const CLAUDE_FILE = "CLAUDE.md";
1128
1485
  const RULES_DIR = ".claude/rules/tunestra";
1129
1486
  const SKILLS_DIR = ".claude/skills";
1130
1487
  const COMMANDS_DIR = ".claude/commands";
1488
+ const SUBAGENTS_DIR$1 = ".claude/agents";
1131
1489
  const MCP_FILE = ".mcp.json";
1132
1490
  const MCP_SOURCE = "the mcp/ folders of tunestra.yaml and its presets";
1133
1491
  const SETTINGS_FILE = ".claude/settings.json";
1134
1492
  const SETTINGS_SOURCE = "env and permissions in tunestra.yaml and its presets";
1135
1493
  const LOCAL_SETTINGS_FILE = ".claude/settings.local.json";
1494
+ const LOCAL_CLAUDE_FILE = "CLAUDE.local.md";
1495
+ const LOCAL_SOURCE = `${LOCAL_CONFIG} and ${LOCAL_DIR}/`;
1136
1496
  /**
1137
1497
  * Claude Code reads CLAUDE.md, so it imports AGENTS.md. Rules are copied to `.claude/rules`,
1138
1498
  * which Claude Code loads in every session; a symlink to a directory outside `.claude`
@@ -1142,8 +1502,40 @@ const LOCAL_SETTINGS_FILE = ".claude/settings.local.json";
1142
1502
  */
1143
1503
  const claudeAdapter = {
1144
1504
  agent: Agent.Claude,
1145
- plan: planClaude
1505
+ plan: planClaude,
1506
+ planLocal: planClaudeLocal
1146
1507
  };
1508
+ /**
1509
+ * Claude Code reads `CLAUDE.local.md` after CLAUDE.md, so it says what the personal level
1510
+ * changes. Personal env and permissions are added to `.claude/settings.local.json`, which
1511
+ * Claude Code applies over the committed settings; the entries of people and of Claude
1512
+ * Code itself stay.
1513
+ */
1514
+ function planClaudeLocal(context) {
1515
+ const { shared, local } = context;
1516
+ const content = renderLocalChanges(shared, local);
1517
+ const localFile = createFileOutput(LOCAL_CLAUDE_FILE, content, LOCAL_SOURCE);
1518
+ const env = local.env.filter(isFromLocal);
1519
+ const permissions = local.permissions.filter(isFromLocal);
1520
+ if ((env.length > 0 || permissions.length > 0) === false) return {
1521
+ outputs: [localFile],
1522
+ ignoredPaths: [LOCAL_CLAUDE_FILE],
1523
+ merges: []
1524
+ };
1525
+ const entries = buildSettingsEntries(env, permissions);
1526
+ return {
1527
+ outputs: [localFile],
1528
+ ignoredPaths: [LOCAL_CLAUDE_FILE, LOCAL_SETTINGS_FILE],
1529
+ merges: [{
1530
+ path: LOCAL_SETTINGS_FILE,
1531
+ source: LOCAL_SOURCE,
1532
+ entries
1533
+ }]
1534
+ };
1535
+ }
1536
+ function isFromLocal(resolved) {
1537
+ return resolved.origin.level === Level.Local;
1538
+ }
1147
1539
  function planClaude(context) {
1148
1540
  const { resolution, ruleFiles } = context;
1149
1541
  const projectDir = resolution.chain.project.dir;
@@ -1156,11 +1548,15 @@ function planClaude(context) {
1156
1548
  const source = describeArtifactSource(projectDir, command.origin, command.artifact.source);
1157
1549
  return createFileOutput(path, command.artifact.content, source);
1158
1550
  });
1551
+ const subagentFiles = resolution.subagents.map((subagent) => {
1552
+ return createFileOutput(`${SUBAGENTS_DIR$1}/${subagent.artifact.key}.md`, renderSubagentMd(subagent.artifact), describeArtifactSource(projectDir, subagent.origin, subagent.artifact.source));
1553
+ });
1159
1554
  const outputs = [
1160
1555
  claudeFile,
1161
1556
  ...ruleCopies,
1162
1557
  ...skillLinks,
1163
- ...commandFiles
1558
+ ...commandFiles,
1559
+ ...subagentFiles
1164
1560
  ];
1165
1561
  if (resolution.mcp.length > 0) {
1166
1562
  const mcpJson = renderMcpJson(resolution.mcp);
@@ -1179,10 +1575,12 @@ function planClaude(context) {
1179
1575
  `${RULES_DIR}/`,
1180
1576
  `${SKILLS_DIR}/`,
1181
1577
  `${COMMANDS_DIR}/`,
1578
+ `${SUBAGENTS_DIR$1}/`,
1182
1579
  MCP_FILE
1183
1580
  ],
1184
1581
  protectedPaths,
1185
- notes: []
1582
+ notes: [],
1583
+ merges: []
1186
1584
  };
1187
1585
  }
1188
1586
  /** Claude Code drops some variables from project settings; setup says so instead. */
@@ -1220,362 +1618,48 @@ function linkSkill(projectDir, skill) {
1220
1618
  };
1221
1619
  }
1222
1620
  //#endregion
1223
- //#region src/agents/codex/command-skill.ts
1224
- /** Codex starts the skill only when people call it, like a command. */
1225
- const OPENAI_YAML = [
1226
- GENERATED_HASH_NOTICE,
1227
- "policy:",
1228
- " allow_implicit_invocation: false",
1229
- ""
1230
- ].join("\n");
1621
+ //#region src/generate/rule-file.ts
1231
1622
  /**
1232
- * Codex has no project commands, so a command becomes a skill with the same name that runs
1233
- * only on `$<name>`. The skill gets `name` and `description`; other frontmatter of the
1234
- * command belongs to Claude Code.
1623
+ * `<rank>-<level>-<key>.md`, so that a plain sort puts specific rules first. Level
1624
+ * instructions become `instructions`, with the preset name when it differs from the level.
1235
1625
  */
1236
- function planCommandSkill(projectDir, command) {
1237
- const { key, description, body } = command.artifact;
1238
- const dir = `${AGENTS_SKILLS_DIR}/${key}`;
1239
- const source = describeArtifactSource(projectDir, command.origin, command.artifact.source);
1240
- const frontmatter = {
1241
- name: key,
1242
- description: description ?? `The /${key} command`
1243
- };
1244
- const skill = `---\n${stringify(frontmatter)}---\n${body}`;
1245
- const skillPath = `${dir}/SKILL.md`;
1246
- const policyPath = `${dir}/agents/openai.yaml`;
1247
- return [createFileOutput(skillPath, skill, source), createFileOutput(policyPath, OPENAI_YAML, source)];
1248
- }
1249
- //#endregion
1250
- //#region src/agents/codex/config-toml.ts
1251
- /** Starts and ends a multi-line literal string, where TOML escapes nothing. */
1252
- const LITERAL_DELIMITER = "'''";
1253
- /**
1254
- * Control characters that a TOML literal string cannot hold. Tab and line feed are allowed;
1255
- * a carriage return is in the list, because only `\r\n` would be valid.
1256
- *
1257
- * Matches: "\u0000", "\r", "\u001B" (escape), "\u007F" (delete)
1258
- * Does not match: "\t", "\n", "é", "→"
1259
- */
1260
- const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000B-\u001F\u007F]/;
1261
- /**
1262
- * `.codex/config.toml`: the rules as `developer_instructions`, which Codex adds to every
1263
- * session, the environment of the commands that Codex runs, and the MCP servers. The rules
1264
- * are a literal string, so that people can read them.
1265
- */
1266
- function renderConfigToml(config) {
1267
- const parts = [GENERATED_HASH_NOTICE];
1268
- if (typeof config.instructions === "string") {
1269
- const line = renderInstructions$1(config.instructions);
1270
- parts.push(line);
1271
- }
1272
- const tables = {};
1273
- const hasEnv = config.env.length > 0;
1274
- const hasServers = config.servers.length > 0;
1275
- if (hasEnv) tables.shell_environment_policy = { set: buildEnvMap(config.env) };
1276
- if (hasServers) {
1277
- const entries = config.servers.map((server) => {
1278
- const serverConfig = convertMcpConfig(server.artifact.config);
1279
- return [server.artifact.key, serverConfig];
1280
- });
1281
- tables.mcp_servers = Object.fromEntries(entries);
1282
- }
1283
- if (hasEnv || hasServers) {
1284
- const text = stringify$1(tables).trimEnd();
1285
- parts.push(text);
1286
- }
1287
- return `${parts.join("\n\n")}\n`;
1288
- }
1289
- function renderInstructions$1(text) {
1290
- const hasDelimiter = text.includes(LITERAL_DELIMITER);
1291
- const hasControlCharacter = CONTROL_CHARACTER_PATTERN.test(text);
1292
- if (hasDelimiter || hasControlCharacter) return stringify$1({ developer_instructions: text });
1293
- return `developer_instructions = ${LITERAL_DELIMITER}\n${text}${LITERAL_DELIMITER}`;
1294
- }
1295
- /** Codex picks the transport by `command` or `url` and has no `type`. */
1296
- function convertMcpConfig(config) {
1297
- const { type: _type, headers, ...server } = config;
1298
- if (headers === void 0) return server;
1299
- return {
1300
- ...server,
1301
- http_headers: headers
1302
- };
1303
- }
1304
- //#endregion
1305
- //#region src/agents/codex/exec-rules.ts
1306
- /** A Bash rule of Claude Code with a specifier: `Bash(npm test *)`. */
1307
- const BASH_RULE_START = "Bash(";
1308
- const BASH_RULE_END = ")";
1309
- /**
1310
- * Shell syntax and wildcards that a Codex prefix rule cannot express.
1311
- *
1312
- * Matches: "npm test && rm", "echo $HOME", "git * --force", "ls > out", "say 'hi'"
1313
- * Does not match: "npm test", "git push --force", "rm -rf"
1314
- */
1315
- const SHELL_SYNTAX_PATTERN = /["'`$\\|&;<>(){}*?[\]~]/;
1316
- const WHITESPACE_PATTERN = /\s+/;
1317
- /** A trailing ` *` or the older `:*` means "this command with any arguments". */
1318
- const ANY_ARGUMENTS_SUFFIXES = [" *", ":*"];
1319
- /**
1320
- * Codex names for the decisions it gets. `allow` stays out: in Codex it runs a command
1321
- * outside the sandbox without asking, which is more than an `allow` rule of Claude Code means.
1322
- */
1323
- const CODEX_DECISIONS = {
1324
- [PermissionDecision.Deny]: "forbidden",
1325
- [PermissionDecision.Ask]: "prompt"
1326
- };
1327
- /**
1328
- * The words of a Bash rule that Codex can match by prefix, or `undefined` when it cannot.
1329
- * A rule without a wildcard becomes a prefix too: for `deny` and `ask` this is stricter.
1330
- */
1331
- function convertBashRule(rule) {
1332
- if ((rule.startsWith(BASH_RULE_START) && rule.endsWith(BASH_RULE_END)) === false) return;
1333
- const specifier = rule.slice(5, -1);
1334
- const suffix = ANY_ARGUMENTS_SUFFIXES.find((ending) => specifier.endsWith(ending));
1335
- const command = typeof suffix === "string" ? specifier.slice(0, -suffix.length) : specifier;
1336
- if (SHELL_SYNTAX_PATTERN.test(command)) return;
1337
- const words = command.trim().split(WHITESPACE_PATTERN);
1338
- if (words.every((word) => word.length > 0)) return words;
1339
- }
1340
- function hasCodexDecision(decision) {
1341
- return decision in CODEX_DECISIONS;
1342
- }
1343
- function createExecRule(resolved, decision, pattern) {
1344
- return {
1345
- pattern,
1346
- decision: CODEX_DECISIONS[decision],
1347
- justification: `${resolved.artifact.key}: ${decision} from ${resolved.origin.name}`
1348
- };
1349
- }
1350
- /** `.codex/rules/tunestra.rules` in the Starlark format of Codex. */
1351
- function renderExecRules(rules) {
1352
- const blocks = rules.map(renderExecRule);
1353
- return `${[GENERATED_HASH_NOTICE, ...blocks].join("\n\n")}\n`;
1354
- }
1355
- /** JSON strings are valid Starlark strings. */
1356
- function renderExecRule(rule) {
1357
- const pattern = rule.pattern.map((word) => JSON.stringify(word)).join(", ");
1358
- const decision = JSON.stringify(rule.decision);
1359
- const justification = JSON.stringify(rule.justification);
1360
- return [
1361
- "prefix_rule(",
1362
- ` pattern = [${pattern}],`,
1363
- ` decision = ${decision},`,
1364
- ` justification = ${justification},`,
1365
- ")"
1366
- ].join("\n");
1367
- }
1368
- //#endregion
1369
- //#region src/agents/codex/codex-adapter.ts
1370
- const CONFIG_FILE = ".codex/config.toml";
1371
- const CONFIG_SOURCE = "the rules/ and mcp/ folders and env of tunestra.yaml and its presets";
1372
- const EXEC_RULES_FILE = ".codex/rules/tunestra.rules";
1373
- const EXEC_RULES_SOURCE = "permissions in tunestra.yaml and its presets";
1374
- const INSTRUCTIONS_INTRO = "Rules of this project, ordered from specific to general. When two rules conflict, follow the one that comes first.";
1375
- const TRUST_NOTE = "Codex reads .codex/ only in a trusted project: trust the project when Codex asks.";
1376
- /**
1377
- * Codex reads AGENTS.md and the skills in `.agents/skills` by itself. Rules go to
1378
- * `developer_instructions` in `.codex/config.toml`, because Codex does not follow the links
1379
- * in AGENTS.md; MCP servers and env go to the same file. Commands become skills, and
1380
- * permission rules for shell commands become `.codex/rules/tunestra.rules`.
1381
- */
1382
- const codexAdapter = {
1383
- agent: Agent.Codex,
1384
- plan: planCodex
1385
- };
1386
- function planCodex(context) {
1387
- const { resolution, ruleFiles } = context;
1388
- const projectDir = resolution.chain.project.dir;
1389
- const notes = [];
1390
- const outputs = [];
1391
- const commandSkills = resolution.commands.filter((command) => {
1392
- if (skillName.safeParse(command.artifact.key).success) return true;
1393
- notes.push(`Codex skips the command "${command.artifact.key}": a skill name needs lowercase letters, digits and single hyphens.`);
1394
- return false;
1395
- }).flatMap((command) => planCommandSkill(projectDir, command));
1396
- outputs.push(...commandSkills);
1397
- const configFile = planConfigFile(ruleFiles, resolution.mcp.filter((server) => {
1398
- if (server.artifact.config.type === "sse") {
1399
- notes.push(`Codex skips the MCP server "${server.artifact.key}": Codex does not support SSE.`);
1400
- return false;
1401
- }
1402
- return true;
1403
- }), resolution.env);
1404
- const execRules = planExecRules(resolution.permissions);
1405
- const ignoredPaths = [];
1406
- notes.push(...execRules.notes);
1407
- if (typeof configFile === "object") {
1408
- outputs.push(configFile);
1409
- ignoredPaths.push(CONFIG_FILE);
1410
- }
1411
- if (typeof execRules.file === "object") {
1412
- outputs.push(execRules.file);
1413
- ignoredPaths.push(EXEC_RULES_FILE);
1414
- }
1415
- if (ignoredPaths.length > 0) notes.push(TRUST_NOTE);
1416
- return {
1417
- outputs,
1418
- ignoredPaths,
1419
- protectedPaths: [],
1420
- notes
1421
- };
1422
- }
1423
- /**
1424
- * `deny` and `ask` rules for Bash commands become prefix rules of Codex. Codex has no rules
1425
- * for files, the web or MCP tools, and `allow` would mean more in Codex, so those are skipped.
1426
- */
1427
- function planExecRules(permissions) {
1428
- const execRules = [];
1429
- const skippedRules = [];
1430
- const allowRules = [];
1431
- for (const resolved of permissions) {
1432
- const { key, decision } = resolved.artifact;
1433
- if (hasCodexDecision(decision) === false) {
1434
- allowRules.push(key);
1435
- continue;
1436
- }
1437
- const pattern = convertBashRule(key);
1438
- if (pattern === void 0) {
1439
- skippedRules.push(key);
1440
- continue;
1441
- }
1442
- const execRule = createExecRule(resolved, decision, pattern);
1443
- execRules.push(execRule);
1444
- }
1445
- const notes = [];
1446
- const hasSkippedRules = skippedRules.length > 0;
1447
- const hasAllowRules = allowRules.length > 0;
1448
- if (hasSkippedRules) {
1449
- const rules = skippedRules.join(", ");
1450
- notes.push(`Codex skips permission rules that it cannot enforce: ${rules}. Codex rules match only the start of a shell command.`);
1451
- }
1452
- if (hasAllowRules) {
1453
- const rules = allowRules.join(", ");
1454
- notes.push(`Codex skips allow rules: ${rules}. In Codex, an allowed command runs outside the sandbox without asking.`);
1455
- }
1456
- if (execRules.length > 0) {
1457
- const content = renderExecRules(execRules);
1458
- return {
1459
- file: createFileOutput(EXEC_RULES_FILE, content, EXEC_RULES_SOURCE),
1460
- notes
1461
- };
1462
- }
1463
- return {
1464
- file: void 0,
1465
- notes
1466
- };
1467
- }
1468
- function planConfigFile(ruleFiles, servers, env) {
1469
- const hasRules = ruleFiles.length > 0;
1470
- const hasServers = servers.length > 0;
1471
- const hasEnv = env.length > 0;
1472
- if (hasRules === false && hasServers === false && hasEnv === false) return;
1473
- const content = renderConfigToml({
1474
- instructions: hasRules ? renderInstructions(ruleFiles) : void 0,
1475
- servers,
1476
- env
1477
- });
1478
- return createFileOutput(CONFIG_FILE, content, CONFIG_SOURCE);
1479
- }
1480
- /** The rule files as they are, with the header that names the source of each. */
1481
- function renderInstructions(ruleFiles) {
1482
- const rules = ruleFiles.map((file) => file.content.trimEnd());
1483
- return `${[INSTRUCTIONS_INTRO, ...rules].join("\n\n")}\n`;
1484
- }
1485
- //#endregion
1486
- //#region src/agents/adapters.ts
1487
- /** Every supported agent. */
1488
- const AGENT_ADAPTERS = {
1489
- [Agent.Claude]: claudeAdapter,
1490
- [Agent.Codex]: codexAdapter
1491
- };
1492
- //#endregion
1493
- //#region src/shared/utils/markdown.ts
1494
- /**
1495
- * A pipe or a line break inside a cell would break the table.
1496
- *
1497
- * Matches: "|" in "a | b", "\n" and "\r\n" in "line one\nline two"
1498
- */
1499
- const TABLE_BREAKING_PATTERN = /\||\r?\n/g;
1500
- function renderTable(header, rows) {
1501
- return [
1502
- renderRow(header),
1503
- `|${header.map(() => "---").join("|")}|`,
1504
- ...rows.map(renderRow)
1505
- ].join("\n");
1506
- }
1507
- function renderRow(cells) {
1508
- return `| ${cells.map(escapeCell).join(" | ")} |`;
1509
- }
1510
- function escapeCell(cell) {
1511
- return cell.replace(TABLE_BREAKING_PATTERN, replaceTableBreaking);
1512
- }
1513
- function replaceTableBreaking(match) {
1514
- if (match === "|") return "\\|";
1515
- return " ";
1516
- }
1517
- //#endregion
1518
- //#region src/generate/describe-origin.ts
1519
- /** For example `team, overrides base` or `base, patched by team`. */
1520
- function describeOrigin(resolved) {
1521
- const parts = [resolved.origin.level];
1522
- const hasOverrides = resolved.overrides.length > 0;
1523
- const isPatched = resolved.patchedBy.length > 0;
1524
- if (hasOverrides) {
1525
- const names = joinNames(resolved.overrides);
1526
- parts.push(`overrides ${names}`);
1527
- }
1528
- if (isPatched) {
1529
- const names = joinNames(resolved.patchedBy);
1530
- parts.push(`patched by ${names}`);
1531
- }
1532
- return parts.join(", ");
1533
- }
1534
- function joinNames(origins) {
1535
- return origins.map((origin) => origin.name).join(", ");
1536
- }
1537
- //#endregion
1538
- //#region src/generate/rule-file.ts
1539
- /**
1540
- * `<rank>-<level>-<key>.md`, so that a plain sort puts specific rules first. Level
1541
- * instructions become `instructions`, with the preset name when it differs from the level.
1542
- */
1543
- function buildRuleFileName(rule) {
1544
- const rank = LEVEL_RANK[rule.origin.level];
1545
- const slug = buildRuleSlug(rule);
1546
- return `${rank}-${rule.origin.level}-${slug}.md`;
1547
- }
1548
- /** The rule text with a header that tells where it comes from and what it overrides. */
1549
- function renderRuleFile(rule) {
1550
- const sourceFile = join(rule.origin.artifactsDir, rule.artifact.source);
1551
- const sourcePath = relativePosix(rule.origin.dir, sourceFile);
1552
- const levelLine = renderLevelLine(rule);
1553
- return [
1554
- `${GENERATED_MARKER} from ${rule.origin.name}:${sourcePath}. Do not edit. -->`,
1555
- levelLine,
1556
- "",
1557
- rule.artifact.body
1558
- ].join("\n");
1559
- }
1560
- function buildRuleSlug(rule) {
1561
- const instructionsKey = buildInstructionsKey(rule.origin.name);
1562
- const isInstructions = rule.artifact.key === instructionsKey;
1563
- const isNamedAsLevel = rule.origin.name === rule.origin.level;
1564
- if (isInstructions === false) return rule.artifact.key;
1565
- if (isNamedAsLevel) return "instructions";
1566
- return `instructions-${rule.origin.name}`;
1567
- }
1568
- /** For example `> Level: team, preset: team. Overrides: base.` */
1569
- function renderLevelLine(rule) {
1570
- const line = `> Level: ${rule.origin.level}, preset: ${rule.origin.name}.`;
1571
- if (rule.overrides.length > 0) return `${line} Overrides: ${joinNames(rule.overrides)}.`;
1572
- return line;
1626
+ function buildRuleFileName(rule) {
1627
+ const rank = String(LEVEL_RANK[rule.origin.level]).padStart(2, "0");
1628
+ const slug = buildRuleSlug(rule);
1629
+ return `${rank}-${rule.origin.level}-${slug}.md`;
1630
+ }
1631
+ /** The rule text with a header that tells where it comes from and what it overrides. */
1632
+ function renderRuleFile(rule) {
1633
+ const sourceFile = join(rule.origin.artifactsDir, rule.artifact.source);
1634
+ const sourcePath = relativePosix(rule.origin.dir, sourceFile);
1635
+ const levelLine = renderLevelLine(rule);
1636
+ return [
1637
+ `${GENERATED_MARKER} from ${rule.origin.name}:${sourcePath}. Do not edit. -->`,
1638
+ levelLine,
1639
+ "",
1640
+ rule.artifact.body
1641
+ ].join("\n");
1642
+ }
1643
+ function buildRuleSlug(rule) {
1644
+ const instructionsKey = buildInstructionsKey(rule.origin.name);
1645
+ const isInstructions = rule.artifact.key === instructionsKey;
1646
+ const isNamedAsLevel = rule.origin.name === rule.origin.level;
1647
+ if (isInstructions === false) return rule.artifact.key;
1648
+ if (isNamedAsLevel) return "instructions";
1649
+ return `instructions-${rule.origin.name}`;
1650
+ }
1651
+ /** For example `> Level: team, preset: team. Overrides: base.` */
1652
+ function renderLevelLine(rule) {
1653
+ const line = `> Level: ${rule.origin.level}, preset: ${rule.origin.name}.`;
1654
+ if (rule.overrides.length > 0) return `${line} Overrides: ${joinNames(rule.overrides)}.`;
1655
+ return line;
1573
1656
  }
1574
1657
  //#endregion
1575
1658
  //#region src/generate/index-sections.ts
1576
1659
  const HIERARCHY_INTRO = "Instructions are ordered from specific to general. When two instructions conflict, follow the one from the higher level in this list.";
1577
1660
  const RULES_INTRO = "Always follow these rules. Claude Code and Codex load them automatically; other agents should read them before starting work.";
1578
1661
  const SKILLS_INTRO = "Use a skill when the task matches its description.";
1662
+ const SUBAGENTS_INTRO = "Hand a task to a subagent when the task matches its description.";
1579
1663
  const ENV_INTRO = "Agents set these variables in every session, also for the commands they run. The values are in the presets.";
1580
1664
  const CHANGING_SECTION = [
1581
1665
  "## Changing this setup",
@@ -1678,6 +1762,21 @@ function renderMcpServers(servers) {
1678
1762
  ];
1679
1763
  })));
1680
1764
  }
1765
+ function renderSubagents(subagents) {
1766
+ const table = renderTable([
1767
+ "Subagent",
1768
+ "Level",
1769
+ "When to use"
1770
+ ], subagents.map((subagent) => {
1771
+ const origin = describeOrigin(subagent);
1772
+ return [
1773
+ subagent.artifact.key,
1774
+ origin,
1775
+ subagent.artifact.description
1776
+ ];
1777
+ }));
1778
+ return renderSection("Subagents", SUBAGENTS_INTRO, table);
1779
+ }
1681
1780
  function renderEnv(env) {
1682
1781
  const table = renderTable(["Variable", "Level"], env.map((variable) => {
1683
1782
  const origin = describeOrigin(variable);
@@ -1685,76 +1784,446 @@ function renderEnv(env) {
1685
1784
  }));
1686
1785
  return renderSection("Environment", ENV_INTRO, table);
1687
1786
  }
1688
- /** The project shows its config file; a preset shows its git reference or its path. */
1689
- function describeLevelSource(chain, level) {
1690
- if (level.dir === chain.project.dir) return PROJECT_CONFIG;
1691
- if (typeof level.remote === "string") return level.remote;
1692
- return relativePosix(chain.project.dir, level.dir);
1787
+ /** The project and the personal level show their config files; a preset shows its git reference or its path. */
1788
+ function describeLevelSource(chain, level) {
1789
+ if (level.dir === chain.project.dir) return level.level === Level.Local ? LOCAL_CONFIG : PROJECT_CONFIG;
1790
+ if (typeof level.remote === "string") return level.remote;
1791
+ return relativePosix(chain.project.dir, level.dir);
1792
+ }
1793
+ function renderSection(title, intro, table) {
1794
+ const heading = `## ${title}`;
1795
+ if (intro === void 0) return [
1796
+ heading,
1797
+ "",
1798
+ table
1799
+ ].join("\n");
1800
+ return [
1801
+ heading,
1802
+ "",
1803
+ intro,
1804
+ "",
1805
+ table
1806
+ ].join("\n");
1807
+ }
1808
+ //#endregion
1809
+ //#region src/generate/agents-index.ts
1810
+ /**
1811
+ * AGENTS.md: an index of the setup, not the rules themselves. It lists the levels, every
1812
+ * rule, skill, command, MCP server and subagent, where each comes from and where its file is.
1813
+ */
1814
+ function renderAgentsIndex(resolution) {
1815
+ const title = `# AI agent context: ${resolution.chain.project.name}`;
1816
+ const hierarchy = renderHierarchy(resolution.chain);
1817
+ const sections = [
1818
+ GENERATED_NOTICE,
1819
+ title,
1820
+ hierarchy
1821
+ ];
1822
+ const hasRules = resolution.rules.length > 0;
1823
+ const hasSkills = resolution.skills.length > 0;
1824
+ const hasCommands = resolution.commands.length > 0;
1825
+ const hasMcpServers = resolution.mcp.length > 0;
1826
+ const hasSubagents = resolution.subagents.length > 0;
1827
+ const hasEnv = resolution.env.length > 0;
1828
+ if (hasRules) {
1829
+ const rules = renderRules(resolution.rules);
1830
+ sections.push(rules);
1831
+ }
1832
+ if (hasSkills) {
1833
+ const skills = renderSkills(resolution.skills);
1834
+ sections.push(skills);
1835
+ }
1836
+ if (hasCommands) {
1837
+ const commands = renderCommands(resolution.commands, resolution.chain.project.agents);
1838
+ sections.push(commands);
1839
+ }
1840
+ if (hasMcpServers) {
1841
+ const servers = renderMcpServers(resolution.mcp);
1842
+ sections.push(servers);
1843
+ }
1844
+ if (hasSubagents) {
1845
+ const subagents = renderSubagents(resolution.subagents);
1846
+ sections.push(subagents);
1847
+ }
1848
+ if (hasEnv) {
1849
+ const env = renderEnv(resolution.env);
1850
+ sections.push(env);
1851
+ }
1852
+ sections.push(CHANGING_SECTION);
1853
+ return `${sections.join("\n\n")}\n`;
1854
+ }
1855
+ //#endregion
1856
+ //#region src/agents/codex/command-skill.ts
1857
+ /** Codex starts the skill only when people call it, like a command. */
1858
+ const OPENAI_YAML = [
1859
+ GENERATED_HASH_NOTICE,
1860
+ "policy:",
1861
+ " allow_implicit_invocation: false",
1862
+ ""
1863
+ ].join("\n");
1864
+ /**
1865
+ * Codex has no project commands, so a command becomes a skill with the same name that runs
1866
+ * only on `$<name>`. The skill gets `name` and `description`; other frontmatter of the
1867
+ * command belongs to Claude Code.
1868
+ */
1869
+ function planCommandSkill(projectDir, command) {
1870
+ const { key, description, body } = command.artifact;
1871
+ const dir = `${AGENTS_SKILLS_DIR}/${key}`;
1872
+ const source = describeArtifactSource(projectDir, command.origin, command.artifact.source);
1873
+ const frontmatter = {
1874
+ name: key,
1875
+ description: description ?? `The /${key} command`
1876
+ };
1877
+ const skill = `---\n${stringify(frontmatter)}---\n${body}`;
1878
+ const skillPath = `${dir}/SKILL.md`;
1879
+ const policyPath = `${dir}/agents/openai.yaml`;
1880
+ return [createFileOutput(skillPath, skill, source), createFileOutput(policyPath, OPENAI_YAML, source)];
1881
+ }
1882
+ //#endregion
1883
+ //#region src/agents/codex/toml-text.ts
1884
+ /** Starts and ends a multi-line literal string, where TOML escapes nothing. */
1885
+ const LITERAL_DELIMITER = "'''";
1886
+ /**
1887
+ * Control characters that a TOML literal string cannot hold. Tab and line feed are allowed;
1888
+ * a carriage return is in the list, because only `\r\n` would be valid.
1889
+ *
1890
+ * Matches: "\u0000", "\r", "\u001B" (escape), "\u007F" (delete)
1891
+ * Does not match: "\t", "\n", "é", "→"
1892
+ */
1893
+ const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000B-\u001F\u007F]/;
1894
+ /**
1895
+ * A key with a long text, like `developer_instructions`. The text is a literal string, so
1896
+ * that people can read it; a text that a literal string cannot hold is escaped.
1897
+ */
1898
+ function renderTomlText(key, text) {
1899
+ const hasDelimiter = text.includes(LITERAL_DELIMITER);
1900
+ const hasControlCharacter = CONTROL_CHARACTER_PATTERN.test(text);
1901
+ if (hasDelimiter || hasControlCharacter) return stringify$1({ [key]: text });
1902
+ return `${key} = ${LITERAL_DELIMITER}\n${text}${LITERAL_DELIMITER}`;
1903
+ }
1904
+ //#endregion
1905
+ //#region src/agents/codex/config-toml.ts
1906
+ /**
1907
+ * `.codex/config.toml`: the rules as `developer_instructions`, which Codex adds to every
1908
+ * session, the environment of the commands that Codex runs, and the MCP servers. The rules
1909
+ * are a literal string, so that people can read them.
1910
+ */
1911
+ function renderConfigToml(config) {
1912
+ const parts = [GENERATED_HASH_NOTICE];
1913
+ if (typeof config.instructions === "string") {
1914
+ const line = renderTomlText("developer_instructions", config.instructions);
1915
+ parts.push(line);
1916
+ }
1917
+ const tables = {};
1918
+ const hasEnv = config.env.length > 0;
1919
+ const hasServers = config.servers.length > 0;
1920
+ if (hasEnv) tables.shell_environment_policy = { set: buildEnvMap(config.env) };
1921
+ if (hasServers) {
1922
+ const entries = config.servers.map((server) => {
1923
+ const serverConfig = convertMcpConfig(server.artifact.config);
1924
+ return [server.artifact.key, serverConfig];
1925
+ });
1926
+ tables.mcp_servers = Object.fromEntries(entries);
1927
+ }
1928
+ if (hasEnv || hasServers) {
1929
+ const text = stringify$1(tables).trimEnd();
1930
+ parts.push(text);
1931
+ }
1932
+ return `${parts.join("\n\n")}\n`;
1933
+ }
1934
+ /** Codex picks the transport by `command` or `url` and has no `type`. */
1935
+ function convertMcpConfig(config) {
1936
+ const { type: _type, headers, ...server } = config;
1937
+ if (headers === void 0) return server;
1938
+ return {
1939
+ ...server,
1940
+ http_headers: headers
1941
+ };
1942
+ }
1943
+ //#endregion
1944
+ //#region src/agents/codex/exec-rules.ts
1945
+ /** A Bash rule of Claude Code with a specifier: `Bash(npm test *)`. */
1946
+ const BASH_RULE_START = "Bash(";
1947
+ const BASH_RULE_END = ")";
1948
+ /**
1949
+ * Shell syntax and wildcards that a Codex prefix rule cannot express.
1950
+ *
1951
+ * Matches: "npm test && rm", "echo $HOME", "git * --force", "ls > out", "say 'hi'"
1952
+ * Does not match: "npm test", "git push --force", "rm -rf"
1953
+ */
1954
+ const SHELL_SYNTAX_PATTERN = /["'`$\\|&;<>(){}*?[\]~]/;
1955
+ const WHITESPACE_PATTERN = /\s+/;
1956
+ /** A trailing ` *` or the older `:*` means "this command with any arguments". */
1957
+ const ANY_ARGUMENTS_SUFFIXES = [" *", ":*"];
1958
+ /**
1959
+ * Codex names for the decisions it gets. `allow` stays out: in Codex it runs a command
1960
+ * outside the sandbox without asking, which is more than an `allow` rule of Claude Code means.
1961
+ */
1962
+ const CODEX_DECISIONS = {
1963
+ [PermissionDecision.Deny]: "forbidden",
1964
+ [PermissionDecision.Ask]: "prompt"
1965
+ };
1966
+ /**
1967
+ * The words of a Bash rule that Codex can match by prefix, or `undefined` when it cannot.
1968
+ * A rule without a wildcard becomes a prefix too: for `deny` and `ask` this is stricter.
1969
+ */
1970
+ function convertBashRule(rule) {
1971
+ if ((rule.startsWith(BASH_RULE_START) && rule.endsWith(BASH_RULE_END)) === false) return;
1972
+ const specifier = rule.slice(5, -1);
1973
+ const suffix = ANY_ARGUMENTS_SUFFIXES.find((ending) => specifier.endsWith(ending));
1974
+ const command = typeof suffix === "string" ? specifier.slice(0, -suffix.length) : specifier;
1975
+ if (SHELL_SYNTAX_PATTERN.test(command)) return;
1976
+ const words = command.trim().split(WHITESPACE_PATTERN);
1977
+ if (words.every((word) => word.length > 0)) return words;
1978
+ }
1979
+ function hasCodexDecision(decision) {
1980
+ return decision in CODEX_DECISIONS;
1981
+ }
1982
+ function createExecRule(resolved, decision, pattern) {
1983
+ return {
1984
+ pattern,
1985
+ decision: CODEX_DECISIONS[decision],
1986
+ justification: `${resolved.artifact.key}: ${decision} from ${resolved.origin.name}`
1987
+ };
1988
+ }
1989
+ /** `.codex/rules/tunestra.rules` in the Starlark format of Codex. */
1990
+ function renderExecRules(rules) {
1991
+ const blocks = rules.map(renderExecRule);
1992
+ return `${[GENERATED_HASH_NOTICE, ...blocks].join("\n\n")}\n`;
1693
1993
  }
1694
- function renderSection(title, intro, table) {
1695
- const heading = `## ${title}`;
1696
- if (intro === void 0) return [
1697
- heading,
1698
- "",
1699
- table
1700
- ].join("\n");
1994
+ /** JSON strings are valid Starlark strings. */
1995
+ function renderExecRule(rule) {
1996
+ const pattern = rule.pattern.map((word) => JSON.stringify(word)).join(", ");
1997
+ const decision = JSON.stringify(rule.decision);
1998
+ const justification = JSON.stringify(rule.justification);
1701
1999
  return [
1702
- heading,
1703
- "",
1704
- intro,
1705
- "",
1706
- table
2000
+ "prefix_rule(",
2001
+ ` pattern = [${pattern}],`,
2002
+ ` decision = ${decision},`,
2003
+ ` justification = ${justification},`,
2004
+ ")"
1707
2005
  ].join("\n");
1708
2006
  }
1709
2007
  //#endregion
1710
- //#region src/generate/agents-index.ts
2008
+ //#region src/agents/codex/subagent-toml.ts
1711
2009
  /**
1712
- * AGENTS.md: an index of the setup, not the rules themselves. It lists the levels, every
1713
- * rule, skill, command and MCP server, where each comes from and where its file is.
2010
+ * Codex has no read-only mode for a subagent: it can turn off the shell, but the subagent
2011
+ * still edits files with patches. So the instructions ask it not to.
1714
2012
  */
1715
- function renderAgentsIndex(resolution) {
1716
- const title = `# AI agent context: ${resolution.chain.project.name}`;
1717
- const hierarchy = renderHierarchy(resolution.chain);
1718
- const sections = [
1719
- GENERATED_NOTICE,
1720
- title,
1721
- hierarchy
2013
+ const READ_ONLY_INSTRUCTION = "Do not create, edit or delete files.";
2014
+ /**
2015
+ * `.codex/agents/<name>.toml`. Codex gives a subagent the sandbox, the permissions and the
2016
+ * MCP servers of the main session, so the file has only the model and the instructions.
2017
+ */
2018
+ function renderSubagentToml(subagent) {
2019
+ const { key, description, model, effort, readOnly, body } = subagent;
2020
+ const fields = {
2021
+ name: key,
2022
+ description
2023
+ };
2024
+ if (typeof model.codex === "string") fields.model = model.codex;
2025
+ if (typeof effort === "string") fields.model_reasoning_effort = effort;
2026
+ const instructions = readOnly ? `${READ_ONLY_INSTRUCTION}\n\n${body}` : body;
2027
+ const parts = [
2028
+ GENERATED_HASH_NOTICE,
2029
+ stringify$1(fields).trimEnd(),
2030
+ renderTomlText("developer_instructions", instructions)
1722
2031
  ];
1723
- const hasRules = resolution.rules.length > 0;
1724
- const hasSkills = resolution.skills.length > 0;
1725
- const hasCommands = resolution.commands.length > 0;
1726
- const hasMcpServers = resolution.mcp.length > 0;
1727
- const hasEnv = resolution.env.length > 0;
1728
- if (hasRules) {
1729
- const rules = renderRules(resolution.rules);
1730
- sections.push(rules);
2032
+ if (readOnly) parts.push(stringify$1({ features: { shell_tool: false } }).trimEnd());
2033
+ return `${parts.join("\n\n")}\n`;
2034
+ }
2035
+ //#endregion
2036
+ //#region src/agents/codex/codex-adapter.ts
2037
+ const CONFIG_FILE = ".codex/config.toml";
2038
+ const CONFIG_SOURCE = "the rules/ and mcp/ folders and env of tunestra.yaml and its presets";
2039
+ const EXEC_RULES_FILE = ".codex/rules/tunestra.rules";
2040
+ const EXEC_RULES_SOURCE = "permissions in tunestra.yaml and its presets";
2041
+ const SUBAGENTS_DIR = ".codex/agents";
2042
+ const OVERRIDE_FILE = "AGENTS.override.md";
2043
+ const OVERRIDE_SOURCE = `${LOCAL_CONFIG}, ${LOCAL_DIR}/ and the levels in ${INSTRUCTIONS_FILE}`;
2044
+ const INSTRUCTIONS_INTRO = "Rules of this project, ordered from specific to general. When two rules conflict, follow the one that comes first.";
2045
+ const TRUST_NOTE = "Codex reads .codex/ only in a trusted project: trust the project when Codex asks.";
2046
+ /**
2047
+ * Codex reads AGENTS.md and the skills in `.agents/skills` by itself. Rules go to
2048
+ * `developer_instructions` in `.codex/config.toml`, because Codex does not follow the links
2049
+ * in AGENTS.md; MCP servers and env go to the same file. Commands become skills, and
2050
+ * permission rules for shell commands become `.codex/rules/tunestra.rules`. Subagents go
2051
+ * to `.codex/agents`.
2052
+ */
2053
+ const codexAdapter = {
2054
+ agent: Agent.Codex,
2055
+ plan: planCodex,
2056
+ planLocal: planCodexLocal
2057
+ };
2058
+ function planCodex(context) {
2059
+ const { resolution, ruleFiles } = context;
2060
+ const projectDir = resolution.chain.project.dir;
2061
+ const notes = [];
2062
+ const outputs = [];
2063
+ const commandSkills = resolution.commands.filter((command) => {
2064
+ if (skillName.safeParse(command.artifact.key).success) return true;
2065
+ notes.push(`Codex skips the command "${command.artifact.key}": a skill name needs lowercase letters, digits and single hyphens.`);
2066
+ return false;
2067
+ }).flatMap((command) => planCommandSkill(projectDir, command));
2068
+ outputs.push(...commandSkills);
2069
+ const servers = resolution.mcp.filter((server) => {
2070
+ if (server.artifact.config.type === "sse") {
2071
+ notes.push(`Codex skips the MCP server "${server.artifact.key}": Codex does not support SSE.`);
2072
+ return false;
2073
+ }
2074
+ return true;
2075
+ });
2076
+ const subagentFiles = resolution.subagents.map((subagent) => {
2077
+ return createFileOutput(`${SUBAGENTS_DIR}/${subagent.artifact.key}.toml`, renderSubagentToml(subagent.artifact), describeArtifactSource(projectDir, subagent.origin, subagent.artifact.source));
2078
+ });
2079
+ const readOnlySubagents = resolution.subagents.filter((subagent) => subagent.artifact.readOnly).map((subagent) => subagent.artifact.key);
2080
+ const hasReadOnlySubagents = readOnlySubagents.length > 0;
2081
+ outputs.push(...subagentFiles);
2082
+ if (hasReadOnlySubagents) {
2083
+ const names = readOnlySubagents.join(", ");
2084
+ notes.push(`Codex cannot make subagents read-only: ${names}. It turns off their shell and asks them not to edit files.`);
1731
2085
  }
1732
- if (hasSkills) {
1733
- const skills = renderSkills(resolution.skills);
1734
- sections.push(skills);
2086
+ const configFile = planConfigFile(ruleFiles, servers, resolution.env);
2087
+ const execRules = planExecRules(resolution.permissions);
2088
+ const ignoredPaths = [];
2089
+ notes.push(...execRules.notes);
2090
+ if (typeof configFile === "object") {
2091
+ outputs.push(configFile);
2092
+ ignoredPaths.push(CONFIG_FILE);
1735
2093
  }
1736
- if (hasCommands) {
1737
- const commands = renderCommands(resolution.commands, resolution.chain.project.agents);
1738
- sections.push(commands);
2094
+ if (typeof execRules.file === "object") {
2095
+ outputs.push(execRules.file);
2096
+ ignoredPaths.push(EXEC_RULES_FILE);
1739
2097
  }
1740
- if (hasMcpServers) {
1741
- const servers = renderMcpServers(resolution.mcp);
1742
- sections.push(servers);
2098
+ if (subagentFiles.length > 0) ignoredPaths.push(`${SUBAGENTS_DIR}/`);
2099
+ if (ignoredPaths.length > 0) notes.push(TRUST_NOTE);
2100
+ return {
2101
+ outputs,
2102
+ ignoredPaths,
2103
+ protectedPaths: [],
2104
+ notes,
2105
+ merges: []
2106
+ };
2107
+ }
2108
+ /**
2109
+ * Codex reads `AGENTS.override.md` instead of AGENTS.md in the same folder, so the file
2110
+ * holds the whole index with the personal level.
2111
+ */
2112
+ function planCodexLocal(context) {
2113
+ const index = renderAgentsIndex(context.local);
2114
+ return {
2115
+ outputs: [createFileOutput(OVERRIDE_FILE, index, OVERRIDE_SOURCE)],
2116
+ ignoredPaths: [OVERRIDE_FILE],
2117
+ merges: []
2118
+ };
2119
+ }
2120
+ /**
2121
+ * `deny` and `ask` rules for Bash commands become prefix rules of Codex. Codex has no rules
2122
+ * for files, the web or MCP tools, and `allow` would mean more in Codex, so those are skipped.
2123
+ */
2124
+ function planExecRules(permissions) {
2125
+ const execRules = [];
2126
+ const skippedRules = [];
2127
+ const allowRules = [];
2128
+ for (const resolved of permissions) {
2129
+ const { key, decision } = resolved.artifact;
2130
+ if (hasCodexDecision(decision) === false) {
2131
+ allowRules.push(key);
2132
+ continue;
2133
+ }
2134
+ const pattern = convertBashRule(key);
2135
+ if (pattern === void 0) {
2136
+ skippedRules.push(key);
2137
+ continue;
2138
+ }
2139
+ const execRule = createExecRule(resolved, decision, pattern);
2140
+ execRules.push(execRule);
1743
2141
  }
1744
- if (hasEnv) {
1745
- const env = renderEnv(resolution.env);
1746
- sections.push(env);
2142
+ const notes = [];
2143
+ const hasSkippedRules = skippedRules.length > 0;
2144
+ const hasAllowRules = allowRules.length > 0;
2145
+ if (hasSkippedRules) {
2146
+ const rules = skippedRules.join(", ");
2147
+ notes.push(`Codex skips permission rules that it cannot enforce: ${rules}. Codex rules match only the start of a shell command.`);
1747
2148
  }
1748
- sections.push(CHANGING_SECTION);
1749
- return `${sections.join("\n\n")}\n`;
2149
+ if (hasAllowRules) {
2150
+ const rules = allowRules.join(", ");
2151
+ notes.push(`Codex skips allow rules: ${rules}. In Codex, an allowed command runs outside the sandbox without asking.`);
2152
+ }
2153
+ if (execRules.length > 0) {
2154
+ const content = renderExecRules(execRules);
2155
+ return {
2156
+ file: createFileOutput(EXEC_RULES_FILE, content, EXEC_RULES_SOURCE),
2157
+ notes
2158
+ };
2159
+ }
2160
+ return {
2161
+ file: void 0,
2162
+ notes
2163
+ };
2164
+ }
2165
+ function planConfigFile(ruleFiles, servers, env) {
2166
+ const hasRules = ruleFiles.length > 0;
2167
+ const hasServers = servers.length > 0;
2168
+ const hasEnv = env.length > 0;
2169
+ if (hasRules === false && hasServers === false && hasEnv === false) return;
2170
+ const content = renderConfigToml({
2171
+ instructions: hasRules ? renderInstructions(ruleFiles) : void 0,
2172
+ servers,
2173
+ env
2174
+ });
2175
+ return createFileOutput(CONFIG_FILE, content, CONFIG_SOURCE);
2176
+ }
2177
+ /** The rule files as they are, with the header that names the source of each. */
2178
+ function renderInstructions(ruleFiles) {
2179
+ const rules = ruleFiles.map((file) => file.content.trimEnd());
2180
+ return `${[INSTRUCTIONS_INTRO, ...rules].join("\n\n")}\n`;
1750
2181
  }
1751
2182
  //#endregion
2183
+ //#region src/agents/adapters.ts
2184
+ /** Every supported agent. */
2185
+ const AGENT_ADAPTERS = {
2186
+ [Agent.Claude]: claudeAdapter,
2187
+ [Agent.Codex]: codexAdapter
2188
+ };
2189
+ //#endregion
1752
2190
  //#region src/generate/plan-setup.ts
1753
2191
  /**
1754
- * Builds every output without touching the disk: the shared files that any agent can read
1755
- * (AGENTS.md, `.agents/`), then the native files of each agent of the project.
2192
+ * Builds every output without touching the disk. Without a personal level, it is the plan
2193
+ * of the shared chain. With one, the committed files still come from the shared chain, so
2194
+ * that they are the same for the whole team; the other files come from the chain with the
2195
+ * personal level, and every agent adds the files that tell it about the personal level.
1756
2196
  */
1757
- function planSetup(resolution) {
2197
+ function planSetup(shared, local) {
2198
+ const sharedPlan = planChain(shared);
2199
+ if (local === void 0) return finishPlan(sharedPlan);
2200
+ const localPlan = planChain(local);
2201
+ const committedPaths = sharedPlan.protectedPaths;
2202
+ const sharedOutputs = sharedPlan.outputs.filter((output) => committedPaths.includes(output.path));
2203
+ const localOutputs = localPlan.outputs.filter((output) => localPlan.protectedPaths.includes(output.path) === false);
2204
+ const outputs = [...sharedOutputs, ...localOutputs];
2205
+ const ignoredPaths = [...localPlan.ignoredPaths];
2206
+ const merges = [];
2207
+ const context = {
2208
+ shared,
2209
+ local
2210
+ };
2211
+ for (const agent of local.chain.project.agents) {
2212
+ const agentPlan = AGENT_ADAPTERS[agent].planLocal(context);
2213
+ outputs.push(...agentPlan.outputs);
2214
+ ignoredPaths.push(...agentPlan.ignoredPaths);
2215
+ merges.push(...agentPlan.merges);
2216
+ }
2217
+ return finishPlan({
2218
+ outputs,
2219
+ ignoredPaths,
2220
+ protectedPaths: committedPaths,
2221
+ notes: localPlan.notes,
2222
+ merges
2223
+ });
2224
+ }
2225
+ /** The shared files that any agent can read (AGENTS.md, `.agents/`), then each agent. */
2226
+ function planChain(resolution) {
1758
2227
  const projectDir = resolution.chain.project.dir;
1759
2228
  const index = renderAgentsIndex(resolution);
1760
2229
  const indexFile = createFileOutput(INSTRUCTIONS_FILE, index, SETUP_SOURCE);
@@ -1779,14 +2248,30 @@ function planSetup(resolution) {
1779
2248
  protectedPaths.push(...agentPlan.protectedPaths);
1780
2249
  notes.push(...agentPlan.notes);
1781
2250
  }
1782
- ignoredPaths.push(`${STATE_DIR}/`);
1783
- const problems = findPathConflicts(outputs);
1784
- if (problems.length > 0) throw createProblemsError(problems);
1785
2251
  return {
1786
2252
  outputs,
1787
2253
  ignoredPaths,
1788
2254
  protectedPaths,
1789
- notes
2255
+ notes,
2256
+ merges: []
2257
+ };
2258
+ }
2259
+ /**
2260
+ * The sources of the personal level are always ignored, so that nobody commits them by
2261
+ * mistake; so is the state.
2262
+ */
2263
+ function finishPlan(plan) {
2264
+ const ignoredPaths = [
2265
+ ...plan.ignoredPaths,
2266
+ LOCAL_CONFIG,
2267
+ `${LOCAL_DIR}/`,
2268
+ `${STATE_DIR}/`
2269
+ ];
2270
+ const problems = findPathConflicts(plan.outputs);
2271
+ if (problems.length > 0) throw createProblemsError(problems);
2272
+ return {
2273
+ ...plan,
2274
+ ignoredPaths
1790
2275
  };
1791
2276
  }
1792
2277
  function planRuleFile(projectDir, rule) {
@@ -1830,6 +2315,7 @@ const ArtifactKind = {
1830
2315
  Skills: "skills",
1831
2316
  Commands: "commands",
1832
2317
  Mcp: "mcp",
2318
+ Subagents: "subagents",
1833
2319
  Env: "env"
1834
2320
  };
1835
2321
  //#endregion
@@ -2024,6 +2510,7 @@ function applyInheritance(chain) {
2024
2510
  const skills = inheritArtifacts(chain.levels, ArtifactKind.Skills, (level) => level.skills);
2025
2511
  const commands = inheritArtifacts(chain.levels, ArtifactKind.Commands, (level) => level.commands);
2026
2512
  const mcp = inheritArtifacts(chain.levels, ArtifactKind.Mcp, (level) => level.mcp, patchMcpServers);
2513
+ const subagents = inheritArtifacts(chain.levels, ArtifactKind.Subagents, (level) => level.subagents);
2027
2514
  const env = inheritArtifacts(chain.levels, ArtifactKind.Env, (level) => level.env);
2028
2515
  const permissions = inheritPermissions(chain.levels);
2029
2516
  const problems = [
@@ -2031,6 +2518,7 @@ function applyInheritance(chain) {
2031
2518
  ...skills.problems,
2032
2519
  ...commands.problems,
2033
2520
  ...mcp.problems,
2521
+ ...subagents.problems,
2034
2522
  ...env.problems,
2035
2523
  ...permissions.problems
2036
2524
  ];
@@ -2041,6 +2529,7 @@ function applyInheritance(chain) {
2041
2529
  skills: skills.artifacts,
2042
2530
  commands: commands.artifacts,
2043
2531
  mcp: mcp.artifacts,
2532
+ subagents: subagents.artifacts,
2044
2533
  env: env.artifacts,
2045
2534
  permissions: permissions.artifacts
2046
2535
  };
@@ -2049,9 +2538,20 @@ function applyInheritance(chain) {
2049
2538
  //#region src/shared/schemas/state.ts
2050
2539
  const fileRecordSchema = z.strictObject({ hash: z.string() });
2051
2540
  const symlinkRecordSchema = z.strictObject({ target: z.string() });
2541
+ const ruleList = z.array(z.string()).readonly();
2542
+ const settingsEntriesSchema = z.strictObject({
2543
+ env: z.record(z.string(), z.string()),
2544
+ permissions: z.strictObject({
2545
+ allow: ruleList,
2546
+ ask: ruleList,
2547
+ deny: ruleList
2548
+ })
2549
+ });
2052
2550
  const stateSchema = z.strictObject({
2053
2551
  version: z.literal(1),
2054
- outputs: z.record(z.string(), z.union([fileRecordSchema, symlinkRecordSchema]))
2552
+ outputs: z.record(z.string(), z.union([fileRecordSchema, symlinkRecordSchema])),
2553
+ /** Older states have no merges. */
2554
+ merges: z.record(z.string(), settingsEntriesSchema).default({})
2055
2555
  });
2056
2556
  //#endregion
2057
2557
  //#region src/state/read-state.ts
@@ -2060,15 +2560,16 @@ async function readState(projectDir) {
2060
2560
  const file = join(projectDir, STATE_FILE);
2061
2561
  const content = await readFileIfExists(file);
2062
2562
  if (content === void 0) return createEmptyState();
2063
- return validate(parseJson$1(content, file), stateSchema, `state file ${file}. Delete it and run tunestra setup again`);
2563
+ return validate(parseJson$2(content, file), stateSchema, `state file ${file}. Delete it and run tunestra setup again`);
2064
2564
  }
2065
2565
  function createEmptyState() {
2066
2566
  return {
2067
2567
  version: 1,
2068
- outputs: {}
2568
+ outputs: {},
2569
+ merges: {}
2069
2570
  };
2070
2571
  }
2071
- function parseJson$1(content, file) {
2572
+ function parseJson$2(content, file) {
2072
2573
  try {
2073
2574
  return JSON.parse(content);
2074
2575
  } catch (error) {
@@ -2116,16 +2617,21 @@ async function removeSymlink(path) {
2116
2617
  async function writeState(projectDir, state) {
2117
2618
  const file = join(projectDir, STATE_FILE);
2118
2619
  const dir = dirname(file);
2119
- const entries = Object.keys(state.outputs).sort().map((path) => [path, state.outputs[path]]);
2120
- const outputs = Object.fromEntries(entries);
2620
+ const outputs = sortByKey(state.outputs);
2621
+ const merges = sortByKey(state.merges);
2121
2622
  const content = {
2122
2623
  version: state.version,
2123
- outputs
2624
+ outputs,
2625
+ merges
2124
2626
  };
2125
2627
  const text = `${JSON.stringify(content, null, 2)}\n`;
2126
2628
  await mkdir(dir, { recursive: true });
2127
2629
  await writeFile(file, text);
2128
2630
  }
2631
+ function sortByKey(record) {
2632
+ const sorted = Object.entries(record).toSorted(([left], [right]) => left < right ? -1 : 1);
2633
+ return Object.fromEntries(sorted);
2634
+ }
2129
2635
  //#endregion
2130
2636
  //#region src/sync/remove-outputs.ts
2131
2637
  /** Removes stale outputs, then the directories that they leave empty. */
@@ -2188,10 +2694,10 @@ async function createRecord(output) {
2188
2694
  * object has no keys that tunestra could own, so the result is `undefined`.
2189
2695
  */
2190
2696
  async function findForeignKeys(path, ownedKeys) {
2191
- const data = parseJson(await readFile(path, "utf8"));
2697
+ const data = parseJson$1(await readFile(path, "utf8"));
2192
2698
  if (isKeyValueMap(data)) return Object.keys(data).filter((key) => ownedKeys.includes(key) === false);
2193
2699
  }
2194
- function parseJson(text) {
2700
+ function parseJson$1(text) {
2195
2701
  try {
2196
2702
  return JSON.parse(text);
2197
2703
  } catch (error) {
@@ -2300,6 +2806,117 @@ function createStaleCheck(path, isRemovable, warning) {
2300
2806
  };
2301
2807
  }
2302
2808
  //#endregion
2809
+ //#region src/sync/merge-settings.ts
2810
+ const DECISIONS = Object.values(PermissionDecision);
2811
+ /**
2812
+ * The settings of people with the entries of setup: the entries from the last setup are
2813
+ * taken out, the new ones are added. A variable that someone changed after setup wrote it
2814
+ * is theirs now and stays. Other keys keep their order.
2815
+ */
2816
+ function mergeSettings(current, previous, next) {
2817
+ const result = { ...current };
2818
+ const env = mergeEnv(current.env, previous, next);
2819
+ const permissions = mergePermissions(current.permissions, previous, next);
2820
+ setOrDelete(result, "env", env);
2821
+ setOrDelete(result, "permissions", permissions);
2822
+ return result;
2823
+ }
2824
+ function mergeEnv(current, previous, next) {
2825
+ const env = isKeyValueMap(current) ? { ...current } : {};
2826
+ const previousEnv = previous?.env ?? {};
2827
+ const previousEntries = Object.entries(previousEnv);
2828
+ for (const [name, value] of previousEntries) if (env[name] === value) delete env[name];
2829
+ const nextEnv = next?.env ?? {};
2830
+ return {
2831
+ ...env,
2832
+ ...nextEnv
2833
+ };
2834
+ }
2835
+ function mergePermissions(current, previous, next) {
2836
+ const permissions = isKeyValueMap(current) ? { ...current } : {};
2837
+ for (const decision of DECISIONS) {
2838
+ const list = permissions[decision];
2839
+ const rules = Array.isArray(list) ? list : [];
2840
+ const previousRules = previous?.permissions[decision] ?? [];
2841
+ const nextRules = next?.permissions[decision] ?? [];
2842
+ const keptRules = rules.filter((rule) => previousRules.includes(rule) === false);
2843
+ const addedRules = nextRules.filter((rule) => keptRules.includes(rule) === false);
2844
+ setOrDelete(permissions, decision, [...keptRules, ...addedRules]);
2845
+ }
2846
+ return permissions;
2847
+ }
2848
+ /** An empty list or object is left out, as if nobody had written it. */
2849
+ function setOrDelete(target, key, value) {
2850
+ const isEmptyList = Array.isArray(value) && value.length === 0;
2851
+ const isEmptyRecord = isKeyValueMap(value) && Object.keys(value).length === 0;
2852
+ if (isEmptyList || isEmptyRecord) {
2853
+ delete target[key];
2854
+ return;
2855
+ }
2856
+ target[key] = value;
2857
+ }
2858
+ //#endregion
2859
+ //#region src/sync/plan-merges.ts
2860
+ /**
2861
+ * Adds the entries of every merge to its file and takes the entries of the last setup out,
2862
+ * also from files that this setup does not merge into any more. Reads the disk only.
2863
+ */
2864
+ async function planMerges(projectDir, merges, previous) {
2865
+ const paths = new Set(merges.map((merge) => merge.path));
2866
+ const stalePaths = Object.keys(previous).filter((path) => paths.has(path) === false);
2867
+ const nextSteps = merges.map((merge) => ({
2868
+ path: merge.path,
2869
+ source: merge.source,
2870
+ next: merge.entries
2871
+ }));
2872
+ const staleSteps = stalePaths.map((path) => ({
2873
+ path,
2874
+ source: "the last setup",
2875
+ next: void 0
2876
+ }));
2877
+ const results = await mapSettled([...nextSteps, ...staleSteps], (step) => planMerge(projectDir, step, previous));
2878
+ const writes = results.map((result) => result.write).filter((write) => write !== void 0);
2879
+ const removals = results.map((result) => result.removal).filter((removal) => removal !== void 0);
2880
+ const recordEntries = merges.map((merge) => [merge.path, merge.entries]);
2881
+ return {
2882
+ writes,
2883
+ removals,
2884
+ records: Object.fromEntries(recordEntries)
2885
+ };
2886
+ }
2887
+ async function planMerge(projectDir, step, previous) {
2888
+ const content = await readFileIfExists(join(projectDir, step.path));
2889
+ const current = content === void 0 ? {} : parseSettings(content, step.path);
2890
+ const merged = mergeSettings(current, previous[step.path], step.next);
2891
+ const isEmpty = Object.keys(merged).length === 0;
2892
+ const isUnchanged = JSON.stringify(merged) === JSON.stringify(current);
2893
+ if (isEmpty) return {
2894
+ write: void 0,
2895
+ removal: content === void 0 ? void 0 : step.path
2896
+ };
2897
+ if (isUnchanged && content !== void 0) return {
2898
+ write: void 0,
2899
+ removal: void 0
2900
+ };
2901
+ const json = JSON.stringify(merged, null, 2);
2902
+ return {
2903
+ write: createFileOutput(step.path, `${json}\n`, step.source),
2904
+ removal: void 0
2905
+ };
2906
+ }
2907
+ function parseSettings(content, path) {
2908
+ const data = parseJson(content, path);
2909
+ if (isKeyValueMap(data) === false) throw new TunestraError(`${path} is not a JSON object, so setup cannot add its settings. Fix or delete it and run tunestra setup again`);
2910
+ return data;
2911
+ }
2912
+ function parseJson(content, path) {
2913
+ try {
2914
+ return JSON.parse(content);
2915
+ } catch (error) {
2916
+ throw new TunestraError(`${path} is not valid JSON: ${getErrorMessage(error)}. Fix or delete it and run tunestra setup again`);
2917
+ }
2918
+ }
2919
+ //#endregion
2303
2920
  //#region src/sync/plan-sync.ts
2304
2921
  /** Reads the disk, but writes nothing; problems of every output are reported together. */
2305
2922
  async function planSync(projectDir, plan, previous) {
@@ -2312,16 +2929,18 @@ async function planSync(projectDir, plan, previous) {
2312
2929
  const outputPaths = new Set(paths);
2313
2930
  const staleEntries = Object.entries(previous.outputs).filter(([path]) => outputPaths.has(path) === false);
2314
2931
  const outputChecks = await mapSettled(plan.outputs, (output) => checkOutput(output, context));
2932
+ const mergePlan = await planMerges(projectDir, plan.merges, previous.merges);
2315
2933
  const staleChecks = await mapSettled(staleEntries, ([path, record]) => checkStaleOutput(projectDir, path, record));
2316
- const removals = staleChecks.filter((check) => check.isRemovable).map((check) => check.path);
2934
+ const removals = [...staleChecks.filter((check) => check.isRemovable).map((check) => check.path), ...mergePlan.removals];
2317
2935
  const warnings = [...outputChecks, ...staleChecks].map((check) => check.warning).filter(isString);
2318
2936
  const recordEntries = outputChecks.map((check) => [check.path, check.record]);
2319
2937
  const state = {
2320
2938
  version: 1,
2321
- outputs: Object.fromEntries(recordEntries)
2939
+ outputs: Object.fromEntries(recordEntries),
2940
+ merges: mergePlan.records
2322
2941
  };
2323
2942
  return {
2324
- outputs: plan.outputs,
2943
+ outputs: [...plan.outputs, ...mergePlan.writes],
2325
2944
  removals,
2326
2945
  warnings,
2327
2946
  state
@@ -2375,8 +2994,11 @@ async function updateGitignoreFile(projectDir, paths) {
2375
2994
  */
2376
2995
  async function runSetup(projectDir, options) {
2377
2996
  const chain = await loadChain(projectDir);
2378
- const resolution = applyInheritance(chain);
2379
- const plan = planSetup(resolution);
2997
+ const localChain = await loadLocalChain(chain);
2998
+ const sharedResolution = applyInheritance(chain);
2999
+ const localResolution = localChain === void 0 ? void 0 : applyInheritance(localChain);
3000
+ const resolution = localResolution ?? sharedResolution;
3001
+ const plan = planSetup(sharedResolution, localResolution);
2380
3002
  const previous = await readState(chain.project.dir);
2381
3003
  const sync = await planSync(chain.project.dir, plan, previous);
2382
3004
  const report = {
@@ -2400,6 +3022,7 @@ function describeResolution(resolution) {
2400
3022
  describeSection("Skills", resolution.skills, projectDir),
2401
3023
  describeSection("Commands", resolution.commands, projectDir),
2402
3024
  describeSection("MCP servers", resolution.mcp, projectDir),
3025
+ describeSection("Subagents", resolution.subagents, projectDir),
2403
3026
  describeSection("Environment", resolution.env, projectDir),
2404
3027
  describeSection("Permissions", permissions, projectDir)
2405
3028
  ].filter((section) => section.length > 0).join("\n\n");
@@ -2497,25 +3120,26 @@ async function removeGitignoreBlockFile(projectDir) {
2497
3120
  async function runUninstall(projectDir, options) {
2498
3121
  const state = await readState(projectDir);
2499
3122
  const checks = await mapSettled(Object.entries(state.outputs), ([path, record]) => checkStaleOutput(projectDir, path, record));
2500
- const removals = checks.filter((check) => check.isRemovable).map((check) => check.path);
2501
- const warnings = checks.map((check) => check.warning).filter(isString);
2502
- if (options.dryRun) return {
3123
+ const removable = checks.filter((check) => check.isRemovable);
3124
+ const mergePlan = await planMerges(projectDir, [], state.merges);
3125
+ const removals = [...removable.map((check) => check.path), ...mergePlan.removals];
3126
+ const report = {
2503
3127
  removals,
2504
- warnings
3128
+ updates: mergePlan.writes.map((write) => write.path),
3129
+ warnings: checks.map((check) => check.warning).filter(isString)
2505
3130
  };
3131
+ if (options.dryRun) return report;
2506
3132
  const stateDir = join(projectDir, STATE_DIR);
2507
3133
  const tunestraDir = join(projectDir, PROJECT_DIR);
2508
3134
  await removeOutputs(projectDir, removals);
3135
+ await writeOutputs(projectDir, mergePlan.writes);
2509
3136
  await removeGitignoreBlockFile(projectDir);
2510
3137
  await rm(stateDir, {
2511
3138
  recursive: true,
2512
3139
  force: true
2513
3140
  });
2514
3141
  await removeDirIfEmpty(tunestraDir);
2515
- return {
2516
- removals,
2517
- warnings
2518
- };
3142
+ return report;
2519
3143
  }
2520
3144
  //#endregion
2521
3145
  //#region src/cli/commands/uninstall.ts
@@ -2526,12 +3150,14 @@ async function describeUninstall(projectDir, options) {
2526
3150
  const count = report.removals.length;
2527
3151
  if (options.dryRun) {
2528
3152
  const removals = report.removals.map((path) => ` remove ${path}`);
2529
- lines.push(...removals, "", "Nothing was removed (--dry-run).");
3153
+ const updates = report.updates.map((path) => ` update ${path}`);
3154
+ lines.push(...removals, ...updates, "", "Nothing was removed (--dry-run).");
2530
3155
  return lines.join("\n");
2531
3156
  }
2532
3157
  const check = pc.green("✔");
2533
3158
  lines.push(`${check} Removed ${count} generated files and the tunestra block in .gitignore.`);
2534
- lines.push("tunestra.yaml and .tunestra/ are kept: they are your sources.");
3159
+ for (const path of report.updates) lines.push(`Took the settings of tunestra out of ${path}; your settings stay.`);
3160
+ lines.push("tunestra.yaml, tunestra.local.yaml and their folders are kept: they are your sources.");
2535
3161
  return lines.join("\n");
2536
3162
  }
2537
3163
  //#endregion
@@ -2554,9 +3180,12 @@ async function runCommand(action) {
2554
3180
  //#endregion
2555
3181
  //#region src/cli/index.ts
2556
3182
  const program = new Command().name("tunestra").description("Tune your AI agents to play as one orchestra.").version(VERSION);
2557
- program.command("init").description("Create tunestra.yaml and move hand-written AGENTS.md and CLAUDE.md to .tunestra/").option("--extends <presets...>", "presets to extend, from general to specific", []).action(async (options) => {
3183
+ program.command("init").description("Create tunestra.yaml and move hand-written AGENTS.md and CLAUDE.md to .tunestra/").option("--extends <presets...>", "presets to extend, from general to specific", []).option("--local", "create only your personal level: tunestra.local.yaml and .tunestra.local/", false).action(async (options) => {
2558
3184
  const projectDir = process.cwd();
2559
- const initOptions = { presets: options.extends };
3185
+ const initOptions = {
3186
+ presets: options.extends,
3187
+ local: options.local
3188
+ };
2560
3189
  await runCommand(() => describeInit(projectDir, initOptions));
2561
3190
  });
2562
3191
  program.command("setup").description("Generate agent configuration from tunestra.yaml and its presets").option("--dry-run", "show the plan without writing files", false).option("-v, --verbose", "show where every artifact comes from", false).action(async (options) => {