tunestra 0.5.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-CZK2Ixbt.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,
@@ -382,6 +446,15 @@ const projectConfigSchema = z.strictObject({
382
446
  env: envSchema.default({}),
383
447
  permissions: permissionsSchema.default({})
384
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
+ });
385
458
  const mcpServerSchema = z.strictObject({
386
459
  description: z.string().optional(),
387
460
  type: z.enum([
@@ -701,12 +774,12 @@ const PermissionDecision = {
701
774
  };
702
775
  //#endregion
703
776
  //#region src/presets/permissions.ts
704
- const DECISIONS$1 = Object.values(PermissionDecision);
777
+ const DECISIONS$2 = Object.values(PermissionDecision);
705
778
  /** The `permissions` lists of a manifest as one list of rules, in the order of the file. */
706
779
  function createPermissionRules(permissions, manifestPath, artifactsDir) {
707
780
  const source = relativePosix(artifactsDir, manifestPath);
708
781
  const rules = [];
709
- for (const decision of DECISIONS$1) {
782
+ for (const decision of DECISIONS$2) {
710
783
  const keys = permissions[decision] ?? [];
711
784
  for (const key of keys) rules.push({
712
785
  key,
@@ -808,6 +881,7 @@ function createDownloadOptions(dir, token) {
808
881
  }
809
882
  //#endregion
810
883
  //#region src/sources/create-source-resolver.ts
884
+ const HOME_PREFIX = "~/";
811
885
  function createSourceResolver(projectDir, download = downloadRepository) {
812
886
  const downloads = /* @__PURE__ */ new Map();
813
887
  const repositories = [];
@@ -837,7 +911,7 @@ function createSourceResolver(projectDir, download = downloadRepository) {
837
911
  const repository = await fetchRepository(gitSource);
838
912
  return checkInside(repository, join(repository.root, gitSource.path), ref);
839
913
  }
840
- const dir = resolve(fromDir, ref);
914
+ const dir = resolveLocalPath(ref, fromDir);
841
915
  const repository = findRepository(fromDir);
842
916
  if (repository === void 0) return dir;
843
917
  return checkInside(repository, dir, ref);
@@ -867,6 +941,17 @@ function isInside(root, dir) {
867
941
  const isOtherDrive = isAbsolute(path);
868
942
  return isParent === false && isOtherDrive === false;
869
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
+ }
870
955
  //#endregion
871
956
  //#region src/chain/check-chain.ts
872
957
  /** A level can extend the same or a more general level, never a more specific one. */
@@ -898,7 +983,7 @@ function findDuplicateNames(levels) {
898
983
  //#endregion
899
984
  //#region src/chain/linearize-chain.ts
900
985
  /**
901
- * 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
902
987
  * order of `extends`, then the level itself. A level that is reached twice is added once.
903
988
  */
904
989
  function linearizeChain(graph) {
@@ -920,7 +1005,7 @@ function linearizeChain(graph) {
920
1005
  if (level === void 0) return;
921
1006
  levels.push(level);
922
1007
  };
923
- visit(graph.project.dir, []);
1008
+ visit(graph.root.dir, []);
924
1009
  return {
925
1010
  levels,
926
1011
  problems: [...problems]
@@ -960,14 +1045,14 @@ async function loadPreset(dir) {
960
1045
  //#endregion
961
1046
  //#region src/chain/load-preset-graph.ts
962
1047
  /**
963
- * 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
964
1049
  * its presets in parallel; a preset that several levels extend is loaded once.
965
1050
  */
966
- async function loadPresetGraph(project, resolver) {
1051
+ async function loadPresetGraph(root, resolver) {
967
1052
  const levels = /* @__PURE__ */ new Map();
968
1053
  const parents = /* @__PURE__ */ new Map();
969
- let pending = [project];
970
- levels.set(project.dir, project);
1054
+ let pending = [root];
1055
+ levels.set(root.dir, root);
971
1056
  while (pending.length > 0) {
972
1057
  const dirsToLoad = /* @__PURE__ */ new Set();
973
1058
  for (const preset of pending) {
@@ -979,7 +1064,7 @@ async function loadPresetGraph(project, resolver) {
979
1064
  for (const preset of pending) levels.set(preset.dir, preset);
980
1065
  }
981
1066
  return {
982
- project,
1067
+ root,
983
1068
  levels,
984
1069
  parents
985
1070
  };
@@ -1013,6 +1098,67 @@ async function loadChain(projectDir, options = {}) {
1013
1098
  };
1014
1099
  }
1015
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
1016
1162
  //#region src/generate/describe-source.ts
1017
1163
  /** Files built from the whole chain, like AGENTS.md: there is no single file to edit. */
1018
1164
  const SETUP_SOURCE = "tunestra.yaml and its presets";
@@ -1025,6 +1171,139 @@ function describeArtifactSource(projectDir, origin, source) {
1025
1171
  return relativePosix(projectDir, join(origin.artifactsDir, source));
1026
1172
  }
1027
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
1028
1307
  //#region src/shared/utils/outputs.ts
1029
1308
  function createFileOutput(path, content, source) {
1030
1309
  return {
@@ -1140,7 +1419,7 @@ function compareByName([a], [b]) {
1140
1419
  //#endregion
1141
1420
  //#region src/agents/claude/settings-json.ts
1142
1421
  const SETTINGS_SCHEMA = "https://json.schemastore.org/claude-code-settings.json";
1143
- const DECISIONS = Object.values(PermissionDecision);
1422
+ const DECISIONS$1 = Object.values(PermissionDecision);
1144
1423
  /** The keys that tunestra writes; a file with only these keys is a generated one. */
1145
1424
  const SETTINGS_KEYS = [
1146
1425
  "$schema",
@@ -1156,10 +1435,23 @@ function renderSettingsJson(settings) {
1156
1435
  if (hasPermissions) content.permissions = buildPermissionLists(settings.permissions);
1157
1436
  return `${JSON.stringify(content, null, 2)}\n`;
1158
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
+ }
1159
1451
  /** Only the lists that have rules; a rule repeated on several levels is written once. */
1160
1452
  function buildPermissionLists(permissions) {
1161
1453
  const lists = {};
1162
- for (const decision of DECISIONS) {
1454
+ for (const decision of DECISIONS$1) {
1163
1455
  const keys = permissions.filter((resolved) => resolved.artifact.decision === decision).map((resolved) => resolved.artifact.key);
1164
1456
  const sortedKeys = [...new Set(keys)].toSorted();
1165
1457
  if (sortedKeys.length > 0) lists[decision] = sortedKeys;
@@ -1199,6 +1491,8 @@ const MCP_SOURCE = "the mcp/ folders of tunestra.yaml and its presets";
1199
1491
  const SETTINGS_FILE = ".claude/settings.json";
1200
1492
  const SETTINGS_SOURCE = "env and permissions in tunestra.yaml and its presets";
1201
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}/`;
1202
1496
  /**
1203
1497
  * Claude Code reads CLAUDE.md, so it imports AGENTS.md. Rules are copied to `.claude/rules`,
1204
1498
  * which Claude Code loads in every session; a symlink to a directory outside `.claude`
@@ -1208,8 +1502,40 @@ const LOCAL_SETTINGS_FILE = ".claude/settings.local.json";
1208
1502
  */
1209
1503
  const claudeAdapter = {
1210
1504
  agent: Agent.Claude,
1211
- plan: planClaude
1505
+ plan: planClaude,
1506
+ planLocal: planClaudeLocal
1212
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
+ }
1213
1539
  function planClaude(context) {
1214
1540
  const { resolution, ruleFiles } = context;
1215
1541
  const projectDir = resolution.chain.project.dir;
@@ -1253,7 +1579,8 @@ function planClaude(context) {
1253
1579
  MCP_FILE
1254
1580
  ],
1255
1581
  protectedPaths,
1256
- notes: []
1582
+ notes: [],
1583
+ merges: []
1257
1584
  };
1258
1585
  }
1259
1586
  /** Claude Code drops some variables from project settings; setup says so instead. */
@@ -1291,404 +1618,41 @@ function linkSkill(projectDir, skill) {
1291
1618
  };
1292
1619
  }
1293
1620
  //#endregion
1294
- //#region src/agents/codex/command-skill.ts
1295
- /** Codex starts the skill only when people call it, like a command. */
1296
- const OPENAI_YAML = [
1297
- GENERATED_HASH_NOTICE,
1298
- "policy:",
1299
- " allow_implicit_invocation: false",
1300
- ""
1301
- ].join("\n");
1621
+ //#region src/generate/rule-file.ts
1302
1622
  /**
1303
- * Codex has no project commands, so a command becomes a skill with the same name that runs
1304
- * only on `$<name>`. The skill gets `name` and `description`; other frontmatter of the
1305
- * 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.
1306
1625
  */
1307
- function planCommandSkill(projectDir, command) {
1308
- const { key, description, body } = command.artifact;
1309
- const dir = `${AGENTS_SKILLS_DIR}/${key}`;
1310
- const source = describeArtifactSource(projectDir, command.origin, command.artifact.source);
1311
- const frontmatter = {
1312
- name: key,
1313
- description: description ?? `The /${key} command`
1314
- };
1315
- const skill = `---\n${stringify(frontmatter)}---\n${body}`;
1316
- const skillPath = `${dir}/SKILL.md`;
1317
- const policyPath = `${dir}/agents/openai.yaml`;
1318
- return [createFileOutput(skillPath, skill, source), createFileOutput(policyPath, OPENAI_YAML, source)];
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`;
1319
1630
  }
1320
- //#endregion
1321
- //#region src/agents/codex/toml-text.ts
1322
- /** Starts and ends a multi-line literal string, where TOML escapes nothing. */
1323
- const LITERAL_DELIMITER = "'''";
1324
- /**
1325
- * Control characters that a TOML literal string cannot hold. Tab and line feed are allowed;
1326
- * a carriage return is in the list, because only `\r\n` would be valid.
1327
- *
1328
- * Matches: "\u0000", "\r", "\u001B" (escape), "\u007F" (delete)
1329
- * Does not match: "\t", "\n", "é", "→"
1330
- */
1331
- const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000B-\u001F\u007F]/;
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
- /**
1345
- * `.codex/config.toml`: the rules as `developer_instructions`, which Codex adds to every
1346
- * session, the environment of the commands that Codex runs, and the MCP servers. The rules
1347
- * are a literal string, so that people can read them.
1348
- */
1349
- function renderConfigToml(config) {
1350
- const parts = [GENERATED_HASH_NOTICE];
1351
- if (typeof config.instructions === "string") {
1352
- const line = renderTomlText("developer_instructions", config.instructions);
1353
- parts.push(line);
1354
- }
1355
- const tables = {};
1356
- const hasEnv = config.env.length > 0;
1357
- const hasServers = config.servers.length > 0;
1358
- if (hasEnv) tables.shell_environment_policy = { set: buildEnvMap(config.env) };
1359
- if (hasServers) {
1360
- const entries = config.servers.map((server) => {
1361
- const serverConfig = convertMcpConfig(server.artifact.config);
1362
- return [server.artifact.key, serverConfig];
1363
- });
1364
- tables.mcp_servers = Object.fromEntries(entries);
1365
- }
1366
- if (hasEnv || hasServers) {
1367
- const text = stringify$1(tables).trimEnd();
1368
- parts.push(text);
1369
- }
1370
- return `${parts.join("\n\n")}\n`;
1371
- }
1372
- /** Codex picks the transport by `command` or `url` and has no `type`. */
1373
- function convertMcpConfig(config) {
1374
- const { type: _type, headers, ...server } = config;
1375
- if (headers === void 0) return server;
1376
- return {
1377
- ...server,
1378
- http_headers: headers
1379
- };
1380
- }
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
1474
- //#region src/agents/codex/codex-adapter.ts
1475
- const CONFIG_FILE = ".codex/config.toml";
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";
1480
- const INSTRUCTIONS_INTRO = "Rules of this project, ordered from specific to general. When two rules conflict, follow the one that comes first.";
1481
- const TRUST_NOTE = "Codex reads .codex/ only in a trusted project: trust the project when Codex asks.";
1482
- /**
1483
- * Codex reads AGENTS.md and the skills in `.agents/skills` by itself. Rules go to
1484
- * `developer_instructions` in `.codex/config.toml`, because Codex does not follow the links
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`.
1488
- */
1489
- const codexAdapter = {
1490
- agent: Agent.Codex,
1491
- plan: planCodex
1492
- };
1493
- function planCodex(context) {
1494
- const { resolution, ruleFiles } = context;
1495
- const projectDir = resolution.chain.project.dir;
1496
- const notes = [];
1497
- const outputs = [];
1498
- const commandSkills = resolution.commands.filter((command) => {
1499
- if (skillName.safeParse(command.artifact.key).success) return true;
1500
- notes.push(`Codex skips the command "${command.artifact.key}": a skill name needs lowercase letters, digits and single hyphens.`);
1501
- return false;
1502
- }).flatMap((command) => planCommandSkill(projectDir, command));
1503
- outputs.push(...commandSkills);
1504
- const servers = resolution.mcp.filter((server) => {
1505
- if (server.artifact.config.type === "sse") {
1506
- notes.push(`Codex skips the MCP server "${server.artifact.key}": Codex does not support SSE.`);
1507
- return false;
1508
- }
1509
- return true;
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 {
1536
- outputs,
1537
- ignoredPaths,
1538
- protectedPaths: [],
1539
- notes
1540
- };
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
- }
1582
- return {
1583
- file: void 0,
1584
- notes
1585
- };
1586
- }
1587
- function planConfigFile(ruleFiles, servers, env) {
1588
- const hasRules = ruleFiles.length > 0;
1589
- const hasServers = servers.length > 0;
1590
- const hasEnv = env.length > 0;
1591
- if (hasRules === false && hasServers === false && hasEnv === false) return;
1592
- const content = renderConfigToml({
1593
- instructions: hasRules ? renderInstructions(ruleFiles) : void 0,
1594
- servers,
1595
- env
1596
- });
1597
- return createFileOutput(CONFIG_FILE, content, CONFIG_SOURCE);
1598
- }
1599
- /** The rule files as they are, with the header that names the source of each. */
1600
- function renderInstructions(ruleFiles) {
1601
- const rules = ruleFiles.map((file) => file.content.trimEnd());
1602
- return `${[INSTRUCTIONS_INTRO, ...rules].join("\n\n")}\n`;
1603
- }
1604
- //#endregion
1605
- //#region src/agents/adapters.ts
1606
- /** Every supported agent. */
1607
- const AGENT_ADAPTERS = {
1608
- [Agent.Claude]: claudeAdapter,
1609
- [Agent.Codex]: codexAdapter
1610
- };
1611
- //#endregion
1612
- //#region src/shared/utils/markdown.ts
1613
- /**
1614
- * A pipe or a line break inside a cell would break the table.
1615
- *
1616
- * Matches: "|" in "a | b", "\n" and "\r\n" in "line one\nline two"
1617
- */
1618
- const TABLE_BREAKING_PATTERN = /\||\r?\n/g;
1619
- function renderTable(header, rows) {
1620
- return [
1621
- renderRow(header),
1622
- `|${header.map(() => "---").join("|")}|`,
1623
- ...rows.map(renderRow)
1624
- ].join("\n");
1625
- }
1626
- function renderRow(cells) {
1627
- return `| ${cells.map(escapeCell).join(" | ")} |`;
1628
- }
1629
- function escapeCell(cell) {
1630
- return cell.replace(TABLE_BREAKING_PATTERN, replaceTableBreaking);
1631
- }
1632
- function replaceTableBreaking(match) {
1633
- if (match === "|") return "\\|";
1634
- return " ";
1635
- }
1636
- //#endregion
1637
- //#region src/generate/describe-origin.ts
1638
- /** For example `team, overrides base` or `base, patched by team`. */
1639
- function describeOrigin(resolved) {
1640
- const parts = [resolved.origin.level];
1641
- const hasOverrides = resolved.overrides.length > 0;
1642
- const isPatched = resolved.patchedBy.length > 0;
1643
- if (hasOverrides) {
1644
- const names = joinNames(resolved.overrides);
1645
- parts.push(`overrides ${names}`);
1646
- }
1647
- if (isPatched) {
1648
- const names = joinNames(resolved.patchedBy);
1649
- parts.push(`patched by ${names}`);
1650
- }
1651
- return parts.join(", ");
1652
- }
1653
- function joinNames(origins) {
1654
- return origins.map((origin) => origin.name).join(", ");
1655
- }
1656
- //#endregion
1657
- //#region src/generate/rule-file.ts
1658
- /**
1659
- * `<rank>-<level>-<key>.md`, so that a plain sort puts specific rules first. Level
1660
- * instructions become `instructions`, with the preset name when it differs from the level.
1661
- */
1662
- function buildRuleFileName(rule) {
1663
- const rank = LEVEL_RANK[rule.origin.level];
1664
- const slug = buildRuleSlug(rule);
1665
- return `${rank}-${rule.origin.level}-${slug}.md`;
1666
- }
1667
- /** The rule text with a header that tells where it comes from and what it overrides. */
1668
- function renderRuleFile(rule) {
1669
- const sourceFile = join(rule.origin.artifactsDir, rule.artifact.source);
1670
- const sourcePath = relativePosix(rule.origin.dir, sourceFile);
1671
- const levelLine = renderLevelLine(rule);
1672
- return [
1673
- `${GENERATED_MARKER} from ${rule.origin.name}:${sourcePath}. Do not edit. -->`,
1674
- levelLine,
1675
- "",
1676
- rule.artifact.body
1677
- ].join("\n");
1678
- }
1679
- function buildRuleSlug(rule) {
1680
- const instructionsKey = buildInstructionsKey(rule.origin.name);
1681
- const isInstructions = rule.artifact.key === instructionsKey;
1682
- const isNamedAsLevel = rule.origin.name === rule.origin.level;
1683
- if (isInstructions === false) return rule.artifact.key;
1684
- if (isNamedAsLevel) return "instructions";
1685
- return `instructions-${rule.origin.name}`;
1686
- }
1687
- /** For example `> Level: team, preset: team. Overrides: base.` */
1688
- function renderLevelLine(rule) {
1689
- const line = `> Level: ${rule.origin.level}, preset: ${rule.origin.name}.`;
1690
- if (rule.overrides.length > 0) return `${line} Overrides: ${joinNames(rule.overrides)}.`;
1691
- return line;
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;
1692
1656
  }
1693
1657
  //#endregion
1694
1658
  //#region src/generate/index-sections.ts
@@ -1820,9 +1784,9 @@ function renderEnv(env) {
1820
1784
  }));
1821
1785
  return renderSection("Environment", ENV_INTRO, table);
1822
1786
  }
1823
- /** The project shows its config file; a preset shows its git reference or its path. */
1787
+ /** The project and the personal level show their config files; a preset shows its git reference or its path. */
1824
1788
  function describeLevelSource(chain, level) {
1825
- if (level.dir === chain.project.dir) return PROJECT_CONFIG;
1789
+ if (level.dir === chain.project.dir) return level.level === Level.Local ? LOCAL_CONFIG : PROJECT_CONFIG;
1826
1790
  if (typeof level.remote === "string") return level.remote;
1827
1791
  return relativePosix(chain.project.dir, level.dir);
1828
1792
  }
@@ -1842,59 +1806,424 @@ function renderSection(title, intro, table) {
1842
1806
  ].join("\n");
1843
1807
  }
1844
1808
  //#endregion
1845
- //#region src/generate/agents-index.ts
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`;
1993
+ }
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);
1999
+ return [
2000
+ "prefix_rule(",
2001
+ ` pattern = [${pattern}],`,
2002
+ ` decision = ${decision},`,
2003
+ ` justification = ${justification},`,
2004
+ ")"
2005
+ ].join("\n");
2006
+ }
2007
+ //#endregion
2008
+ //#region src/agents/codex/subagent-toml.ts
2009
+ /**
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.
2012
+ */
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)
2031
+ ];
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.";
1846
2046
  /**
1847
- * AGENTS.md: an index of the setup, not the rules themselves. It lists the levels, every
1848
- * rule, skill, command, MCP server and subagent, where each comes from and where its file is.
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`.
1849
2052
  */
1850
- function renderAgentsIndex(resolution) {
1851
- const title = `# AI agent context: ${resolution.chain.project.name}`;
1852
- const hierarchy = renderHierarchy(resolution.chain);
1853
- const sections = [
1854
- GENERATED_NOTICE,
1855
- title,
1856
- hierarchy
1857
- ];
1858
- const hasRules = resolution.rules.length > 0;
1859
- const hasSkills = resolution.skills.length > 0;
1860
- const hasCommands = resolution.commands.length > 0;
1861
- const hasMcpServers = resolution.mcp.length > 0;
1862
- const hasSubagents = resolution.subagents.length > 0;
1863
- const hasEnv = resolution.env.length > 0;
1864
- if (hasRules) {
1865
- const rules = renderRules(resolution.rules);
1866
- sections.push(rules);
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.`);
1867
2085
  }
1868
- if (hasSkills) {
1869
- const skills = renderSkills(resolution.skills);
1870
- 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);
1871
2093
  }
1872
- if (hasCommands) {
1873
- const commands = renderCommands(resolution.commands, resolution.chain.project.agents);
1874
- sections.push(commands);
2094
+ if (typeof execRules.file === "object") {
2095
+ outputs.push(execRules.file);
2096
+ ignoredPaths.push(EXEC_RULES_FILE);
1875
2097
  }
1876
- if (hasMcpServers) {
1877
- const servers = renderMcpServers(resolution.mcp);
1878
- 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);
1879
2141
  }
1880
- if (hasSubagents) {
1881
- const subagents = renderSubagents(resolution.subagents);
1882
- sections.push(subagents);
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.`);
1883
2148
  }
1884
- if (hasEnv) {
1885
- const env = renderEnv(resolution.env);
1886
- sections.push(env);
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.`);
1887
2152
  }
1888
- sections.push(CHANGING_SECTION);
1889
- return `${sections.join("\n\n")}\n`;
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`;
1890
2181
  }
1891
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
1892
2190
  //#region src/generate/plan-setup.ts
1893
2191
  /**
1894
- * Builds every output without touching the disk: the shared files that any agent can read
1895
- * (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.
1896
2196
  */
1897
- 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) {
1898
2227
  const projectDir = resolution.chain.project.dir;
1899
2228
  const index = renderAgentsIndex(resolution);
1900
2229
  const indexFile = createFileOutput(INSTRUCTIONS_FILE, index, SETUP_SOURCE);
@@ -1919,14 +2248,30 @@ function planSetup(resolution) {
1919
2248
  protectedPaths.push(...agentPlan.protectedPaths);
1920
2249
  notes.push(...agentPlan.notes);
1921
2250
  }
1922
- ignoredPaths.push(`${STATE_DIR}/`);
1923
- const problems = findPathConflicts(outputs);
1924
- if (problems.length > 0) throw createProblemsError(problems);
1925
2251
  return {
1926
2252
  outputs,
1927
2253
  ignoredPaths,
1928
2254
  protectedPaths,
1929
- 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
1930
2275
  };
1931
2276
  }
1932
2277
  function planRuleFile(projectDir, rule) {
@@ -2193,9 +2538,20 @@ function applyInheritance(chain) {
2193
2538
  //#region src/shared/schemas/state.ts
2194
2539
  const fileRecordSchema = z.strictObject({ hash: z.string() });
2195
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
+ });
2196
2550
  const stateSchema = z.strictObject({
2197
2551
  version: z.literal(1),
2198
- 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({})
2199
2555
  });
2200
2556
  //#endregion
2201
2557
  //#region src/state/read-state.ts
@@ -2204,15 +2560,16 @@ async function readState(projectDir) {
2204
2560
  const file = join(projectDir, STATE_FILE);
2205
2561
  const content = await readFileIfExists(file);
2206
2562
  if (content === void 0) return createEmptyState();
2207
- 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`);
2208
2564
  }
2209
2565
  function createEmptyState() {
2210
2566
  return {
2211
2567
  version: 1,
2212
- outputs: {}
2568
+ outputs: {},
2569
+ merges: {}
2213
2570
  };
2214
2571
  }
2215
- function parseJson$1(content, file) {
2572
+ function parseJson$2(content, file) {
2216
2573
  try {
2217
2574
  return JSON.parse(content);
2218
2575
  } catch (error) {
@@ -2260,16 +2617,21 @@ async function removeSymlink(path) {
2260
2617
  async function writeState(projectDir, state) {
2261
2618
  const file = join(projectDir, STATE_FILE);
2262
2619
  const dir = dirname(file);
2263
- const entries = Object.keys(state.outputs).sort().map((path) => [path, state.outputs[path]]);
2264
- const outputs = Object.fromEntries(entries);
2620
+ const outputs = sortByKey(state.outputs);
2621
+ const merges = sortByKey(state.merges);
2265
2622
  const content = {
2266
2623
  version: state.version,
2267
- outputs
2624
+ outputs,
2625
+ merges
2268
2626
  };
2269
2627
  const text = `${JSON.stringify(content, null, 2)}\n`;
2270
2628
  await mkdir(dir, { recursive: true });
2271
2629
  await writeFile(file, text);
2272
2630
  }
2631
+ function sortByKey(record) {
2632
+ const sorted = Object.entries(record).toSorted(([left], [right]) => left < right ? -1 : 1);
2633
+ return Object.fromEntries(sorted);
2634
+ }
2273
2635
  //#endregion
2274
2636
  //#region src/sync/remove-outputs.ts
2275
2637
  /** Removes stale outputs, then the directories that they leave empty. */
@@ -2332,10 +2694,10 @@ async function createRecord(output) {
2332
2694
  * object has no keys that tunestra could own, so the result is `undefined`.
2333
2695
  */
2334
2696
  async function findForeignKeys(path, ownedKeys) {
2335
- const data = parseJson(await readFile(path, "utf8"));
2697
+ const data = parseJson$1(await readFile(path, "utf8"));
2336
2698
  if (isKeyValueMap(data)) return Object.keys(data).filter((key) => ownedKeys.includes(key) === false);
2337
2699
  }
2338
- function parseJson(text) {
2700
+ function parseJson$1(text) {
2339
2701
  try {
2340
2702
  return JSON.parse(text);
2341
2703
  } catch (error) {
@@ -2444,6 +2806,117 @@ function createStaleCheck(path, isRemovable, warning) {
2444
2806
  };
2445
2807
  }
2446
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
2447
2920
  //#region src/sync/plan-sync.ts
2448
2921
  /** Reads the disk, but writes nothing; problems of every output are reported together. */
2449
2922
  async function planSync(projectDir, plan, previous) {
@@ -2456,16 +2929,18 @@ async function planSync(projectDir, plan, previous) {
2456
2929
  const outputPaths = new Set(paths);
2457
2930
  const staleEntries = Object.entries(previous.outputs).filter(([path]) => outputPaths.has(path) === false);
2458
2931
  const outputChecks = await mapSettled(plan.outputs, (output) => checkOutput(output, context));
2932
+ const mergePlan = await planMerges(projectDir, plan.merges, previous.merges);
2459
2933
  const staleChecks = await mapSettled(staleEntries, ([path, record]) => checkStaleOutput(projectDir, path, record));
2460
- 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];
2461
2935
  const warnings = [...outputChecks, ...staleChecks].map((check) => check.warning).filter(isString);
2462
2936
  const recordEntries = outputChecks.map((check) => [check.path, check.record]);
2463
2937
  const state = {
2464
2938
  version: 1,
2465
- outputs: Object.fromEntries(recordEntries)
2939
+ outputs: Object.fromEntries(recordEntries),
2940
+ merges: mergePlan.records
2466
2941
  };
2467
2942
  return {
2468
- outputs: plan.outputs,
2943
+ outputs: [...plan.outputs, ...mergePlan.writes],
2469
2944
  removals,
2470
2945
  warnings,
2471
2946
  state
@@ -2519,8 +2994,11 @@ async function updateGitignoreFile(projectDir, paths) {
2519
2994
  */
2520
2995
  async function runSetup(projectDir, options) {
2521
2996
  const chain = await loadChain(projectDir);
2522
- const resolution = applyInheritance(chain);
2523
- 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);
2524
3002
  const previous = await readState(chain.project.dir);
2525
3003
  const sync = await planSync(chain.project.dir, plan, previous);
2526
3004
  const report = {
@@ -2642,25 +3120,26 @@ async function removeGitignoreBlockFile(projectDir) {
2642
3120
  async function runUninstall(projectDir, options) {
2643
3121
  const state = await readState(projectDir);
2644
3122
  const checks = await mapSettled(Object.entries(state.outputs), ([path, record]) => checkStaleOutput(projectDir, path, record));
2645
- const removals = checks.filter((check) => check.isRemovable).map((check) => check.path);
2646
- const warnings = checks.map((check) => check.warning).filter(isString);
2647
- 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 = {
2648
3127
  removals,
2649
- warnings
3128
+ updates: mergePlan.writes.map((write) => write.path),
3129
+ warnings: checks.map((check) => check.warning).filter(isString)
2650
3130
  };
3131
+ if (options.dryRun) return report;
2651
3132
  const stateDir = join(projectDir, STATE_DIR);
2652
3133
  const tunestraDir = join(projectDir, PROJECT_DIR);
2653
3134
  await removeOutputs(projectDir, removals);
3135
+ await writeOutputs(projectDir, mergePlan.writes);
2654
3136
  await removeGitignoreBlockFile(projectDir);
2655
3137
  await rm(stateDir, {
2656
3138
  recursive: true,
2657
3139
  force: true
2658
3140
  });
2659
3141
  await removeDirIfEmpty(tunestraDir);
2660
- return {
2661
- removals,
2662
- warnings
2663
- };
3142
+ return report;
2664
3143
  }
2665
3144
  //#endregion
2666
3145
  //#region src/cli/commands/uninstall.ts
@@ -2671,12 +3150,14 @@ async function describeUninstall(projectDir, options) {
2671
3150
  const count = report.removals.length;
2672
3151
  if (options.dryRun) {
2673
3152
  const removals = report.removals.map((path) => ` remove ${path}`);
2674
- 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).");
2675
3155
  return lines.join("\n");
2676
3156
  }
2677
3157
  const check = pc.green("✔");
2678
3158
  lines.push(`${check} Removed ${count} generated files and the tunestra block in .gitignore.`);
2679
- 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.");
2680
3161
  return lines.join("\n");
2681
3162
  }
2682
3163
  //#endregion
@@ -2699,9 +3180,12 @@ async function runCommand(action) {
2699
3180
  //#endregion
2700
3181
  //#region src/cli/index.ts
2701
3182
  const program = new Command().name("tunestra").description("Tune your AI agents to play as one orchestra.").version(VERSION);
2702
- 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) => {
2703
3184
  const projectDir = process.cwd();
2704
- const initOptions = { presets: options.extends };
3185
+ const initOptions = {
3186
+ presets: options.extends,
3187
+ local: options.local
3188
+ };
2705
3189
  await runCommand(() => describeInit(projectDir, initOptions));
2706
3190
  });
2707
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) => {