@elu-labs/elu 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSES/Apache-2.0.txt +202 -0
  3. package/README.md +327 -2
  4. package/SECURITY.md +333 -0
  5. package/THIRD_PARTY_NOTICES.md +62 -0
  6. package/bin/elu-code.mjs +209 -0
  7. package/bin/elu.cjs +10 -0
  8. package/bin/start.cjs +6 -0
  9. package/docs/peer-network.md +257 -0
  10. package/docs/permissions.md +285 -0
  11. package/docs/vendors/antigravity.md +131 -0
  12. package/docs/vendors/claude.md +124 -0
  13. package/docs/vendors/codex.md +64 -0
  14. package/docs/vendors/grok.md +151 -0
  15. package/lib/acp-client.mjs +100 -0
  16. package/lib/adapters/agy.mjs +283 -0
  17. package/lib/adapters/claude.mjs +311 -0
  18. package/lib/adapters/codex.mjs +137 -0
  19. package/lib/adapters/grok.mjs +384 -0
  20. package/lib/adapters/index.mjs +13 -0
  21. package/lib/agents.mjs +77 -0
  22. package/lib/agy-hook.mjs +53 -0
  23. package/lib/approval-server.mjs +85 -0
  24. package/lib/approvals.mjs +128 -0
  25. package/lib/awake.mjs +45 -0
  26. package/lib/board.mjs +261 -0
  27. package/lib/bot-models.mjs +71 -0
  28. package/lib/bot-schedules.mjs +291 -0
  29. package/lib/bots.mjs +361 -0
  30. package/lib/changes.mjs +168 -0
  31. package/lib/checks.mjs +102 -0
  32. package/lib/conductor.mjs +337 -0
  33. package/lib/config.mjs +147 -0
  34. package/lib/conformance.mjs +109 -0
  35. package/lib/default-bots.mjs +150 -0
  36. package/lib/detect.mjs +108 -0
  37. package/lib/diff.mjs +217 -0
  38. package/lib/editor.mjs +165 -0
  39. package/lib/format.mjs +51 -0
  40. package/lib/git.mjs +173 -0
  41. package/lib/init.mjs +129 -0
  42. package/lib/install.mjs +130 -0
  43. package/lib/kanban.mjs +180 -0
  44. package/lib/mcp.mjs +79 -0
  45. package/lib/memory.mjs +35 -0
  46. package/lib/models.mjs +144 -0
  47. package/lib/modes.mjs +95 -0
  48. package/lib/never-list.mjs +110 -0
  49. package/lib/never-shims.mjs +311 -0
  50. package/lib/paste.mjs +56 -0
  51. package/lib/peer/a2a-http.mjs +166 -0
  52. package/lib/peer/a2a.mjs +357 -0
  53. package/lib/peer/asks.mjs +329 -0
  54. package/lib/peer/canonical.mjs +72 -0
  55. package/lib/peer/cli.mjs +154 -0
  56. package/lib/peer/daemon.mjs +525 -0
  57. package/lib/peer/identity.mjs +81 -0
  58. package/lib/peer/index.mjs +291 -0
  59. package/lib/peer/intake.mjs +207 -0
  60. package/lib/peer/invite.mjs +76 -0
  61. package/lib/peer/paths.mjs +7 -0
  62. package/lib/peer/peerdata.mjs +21 -0
  63. package/lib/peer/pictures.mjs +295 -0
  64. package/lib/peer/plans.mjs +182 -0
  65. package/lib/peer/relay.mjs +31 -0
  66. package/lib/peer/replies.mjs +128 -0
  67. package/lib/peer/roster.mjs +256 -0
  68. package/lib/peer/rpc.mjs +88 -0
  69. package/lib/peer/secrets.mjs +88 -0
  70. package/lib/peer/settings.mjs +45 -0
  71. package/lib/peer/shares.mjs +120 -0
  72. package/lib/peer/status.mjs +84 -0
  73. package/lib/peer/taskstore.mjs +108 -0
  74. package/lib/peer/transport.mjs +120 -0
  75. package/lib/pictures.mjs +104 -0
  76. package/lib/plan.mjs +238 -0
  77. package/lib/policy.mjs +98 -0
  78. package/lib/process.mjs +92 -0
  79. package/lib/project-settings.mjs +55 -0
  80. package/lib/project-trust.mjs +230 -0
  81. package/lib/projects.mjs +668 -0
  82. package/lib/publish.mjs +105 -0
  83. package/lib/runner.mjs +874 -0
  84. package/lib/sandbox.mjs +102 -0
  85. package/lib/schedules.mjs +270 -0
  86. package/lib/selection.mjs +202 -0
  87. package/lib/shell.mjs +101 -0
  88. package/lib/state.mjs +247 -0
  89. package/lib/terminal-bots.mjs +239 -0
  90. package/lib/terminal-commands.mjs +165 -0
  91. package/lib/terminal-plan.mjs +37 -0
  92. package/lib/terminal-visuals.mjs +268 -0
  93. package/lib/terminal.mjs +3159 -0
  94. package/lib/unit-file.mjs +17 -0
  95. package/lib/usage.mjs +249 -0
  96. package/lib/util.mjs +86 -0
  97. package/lib/vendor-check.mjs +96 -0
  98. package/lib/vendors.mjs +191 -0
  99. package/lib/workspace.mjs +168 -0
  100. package/lib/yaml.mjs +624 -0
  101. package/npm-shrinkwrap.json +827 -0
  102. package/package.json +70 -4
@@ -0,0 +1,130 @@
1
+ // Starts ELU Code. Started through npx, it first makes `elu` a command, so the next start is
2
+ // one word, and it runs the installed copy when there is one: the copy npx fetched sits in
3
+ // npm's cache, which npm may clear, so nothing lasting may name it.
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+ import { cleanPath, findBin, quote } from './util.mjs';
10
+
11
+ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
12
+ const PACKAGE = '@elu-labs/elu', COMMAND = 'elu';
13
+ export const BY_HAND = `npm install --global ${PACKAGE}`;
14
+
15
+ // npx keeps what it runs under _npx in npm's cache. Only such a copy offers to install the command.
16
+ export const fromNpx = (folder = ROOT) => path.resolve(folder).split(path.sep).includes('_npx');
17
+
18
+ // What npm is started with. npx exports the settings of the folder it was started in as
19
+ // npm_config_* (that folder's .npmrc among them); none of those is passed on, so only the
20
+ // user's own ~/.npmrc decides the registry and the global folder.
21
+ const KEPT = /^(PATH|HOME|USER|LOGNAME|SHELL|LANG|LC_[A-Z]+|TERM|TMPDIR|HTTPS?_PROXY|NO_PROXY|https?_proxy|no_proxy|SSL_CERT_FILE|SSL_CERT_DIR|NODE_EXTRA_CA_CERTS)$/;
22
+ export const npmEnv = (env = process.env) => Object.fromEntries(Object.entries(env).filter(([name]) => KEPT.test(name)));
23
+
24
+ // npm beside the running node, else the first on PATH. npm's own launcher is a script, and
25
+ // it is run with this node, not with whichever node PATH names.
26
+ export function npmCommand(env = process.env, node = process.execPath) {
27
+ let file = null;
28
+ try { const beside = path.join(path.dirname(node), 'npm'); fs.accessSync(beside, fs.constants.X_OK); file = fs.realpathSync(beside); }
29
+ catch { file = findBin('npm', env); }
30
+ if (!file) return null;
31
+ return /\.c?js$/.test(file) ? [node, file] : [file];
32
+ }
33
+ const runNpm = (npm, args, options) => spawnSync(npm[0], [...npm.slice(1), ...args], { ...options, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
34
+
35
+ const manifest = folder => JSON.parse(fs.readFileSync(path.join(folder, 'package.json'), 'utf8'));
36
+ // The package behind the first `elu` in these folders. npm links <prefix>/bin/elu to the
37
+ // package's own bin folder; any other program of that name has no package of ours behind it.
38
+ function look(folders) {
39
+ const file = findBin(COMMAND, { PATH: folders });
40
+ if (!file) return null;
41
+ try { const root = path.dirname(path.dirname(file)), { name, version } = manifest(root); return { file, root, name, version }; }
42
+ catch { return { file, root: null, name: null, version: null }; }
43
+ }
44
+ // True when this copy is the installed command: the package behind the first `elu` on PATH,
45
+ // or behind ~/.local/bin/elu, where the first start puts it when npm's own folder cannot be
46
+ // written. Only such a copy may be named by something lasting, a login item or a service. A
47
+ // copy a package runner fetched is neither, wherever that runner keeps it.
48
+ export function isInstalled({ root = ROOT, env = process.env, home = os.homedir() } = {}) {
49
+ let mine;
50
+ try { mine = fs.realpathSync(root); } catch { return false; }
51
+ return [cleanPath(env.PATH), path.join(home, '.local', 'bin')].some(folders => look(folders)?.root === mine);
52
+ }
53
+ // The npm prefix a copy was installed under, when it has npm's layout.
54
+ function prefixOf(root) {
55
+ const prefix = path.resolve(root, '..', '..', '..', '..');
56
+ return path.join(prefix, 'lib', 'node_modules', ...PACKAGE.split('/')) === root ? prefix : null;
57
+ }
58
+ function writable(prefix) {
59
+ return ['bin', path.join('lib', 'node_modules')].every(part => {
60
+ let folder = path.join(prefix, part);
61
+ while (!fs.existsSync(folder) && path.dirname(folder) !== folder) folder = path.dirname(folder);
62
+ try { fs.accessSync(folder, fs.constants.W_OK | fs.constants.X_OK); return true; } catch { return false; }
63
+ });
64
+ }
65
+ // The copy npx runs can be older than the installed command: a version asked for by number,
66
+ // or one an old npm kept. An older copy never replaces a newer command.
67
+ function newer(installed, mine) {
68
+ const [a, b] = [installed, mine].map(value => String(value).split('-')[0].split('.').map(Number));
69
+ for (let i = 0; i < 3; i++) if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0);
70
+ return false;
71
+ }
72
+ const shown = arg => /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : quote(arg);
73
+ const reason = result => result.error?.code === 'ETIMEDOUT' ? 'npm took too long'
74
+ : ((result.stderr ?? '').split('\n').map(line => line.replace(/^npm (error|ERR!)\s*/, '').trim()).find(Boolean) ?? result.error?.message ?? 'npm stopped').slice(0, 160);
75
+
76
+ // Decides which copy a start through npx runs, installing the command first when that is
77
+ // wanted. Returns the installed copy's folder, or null to run the copy npx fetched.
78
+ // ELU_NO_INSTALL=1 installs nothing. What is installed is this package at this copy's
79
+ // version, by name: nothing in the environment or the current folder can name another.
80
+ export function prepare({ argv = [], env = process.env, interactive = false, write = () => {}, root = ROOT, home = os.homedir(), npm = npmCommand(env), run = runNpm } = {}) {
81
+ const version = manifest(root).version, local = path.join(home, '.local');
82
+ const first = look(env.PATH ?? ''), aside = first ? null : look(path.join(local, 'bin'));
83
+ const have = first ?? aside, wanted = interactive && argv.length === 0 && !env.ELU_NO_INSTALL;
84
+ const startup = /zsh$/.test(env.SHELL ?? '') ? '~/.zshrc' : /bash$/.test(env.SHELL ?? '') ? (process.platform === 'darwin' ? '~/.bash_profile' : '~/.bashrc') : "your shell's startup file";
85
+ const offPath = folder => `${folder} is not on your PATH yet. Add this line to ${startup} and open a new terminal: export PATH="${folder}:$PATH"\n`;
86
+ if (have?.name === PACKAGE && have.version === version) {
87
+ if (aside && wanted) write(offPath(path.join(local, 'bin')));
88
+ return have.root;
89
+ }
90
+ if (!wanted) return null;
91
+ if (have?.name === PACKAGE && newer(have.version, version)) { write(`The ${COMMAND} command on this computer is newer (${have.version}) than this copy (${version}), so it was left as it is.\n`); return null; }
92
+ if (first && first.name !== PACKAGE) { write(`Another program named ${COMMAND} is first on your PATH (${first.file}), so the ${COMMAND} command was not installed.\n`); return null; }
93
+ if (!npm) { write(`npm was not found, so the ${COMMAND} command was not installed. To install it yourself: ${BY_HAND}\n`); return null; }
94
+ // npm runs from the home folder with its own settings; see npmEnv.
95
+ const settings = { cwd: home, env: npmEnv(env) };
96
+ // Not `npm config get prefix`: npm will not print a value it takes for a secret, and it
97
+ // takes a folder name with an id in it for one.
98
+ const asked = run(npm, ['prefix', '--global'], { ...settings, timeout: 15000 });
99
+ const standard = asked.status === 0 ? asked.stdout.trim() : '';
100
+ // An update goes where the command already is. A first install goes to npm's global
101
+ // folder, or under ~/.local when that folder cannot be written.
102
+ const target = (have?.root && prefixOf(have.root)) ?? (path.isAbsolute(standard) && writable(standard) ? standard : local);
103
+ const args = ['install', '--global', ...(target === standard ? [] : ['--prefix', target]), '--ignore-scripts', `${PACKAGE}@${version}`];
104
+ write(`Installing the ${COMMAND} command: npm ${args.map(shown).join(' ')}\n`);
105
+ const result = run(npm, args, { ...settings, timeout: 180000 });
106
+ const made = look(path.join(target, 'bin'));
107
+ if (result.status !== 0 || made?.name !== PACKAGE || made.version !== version) {
108
+ write(`That did not work (${reason(result)}). ELU Code opens anyway. To install the command yourself: ${BY_HAND}\n`);
109
+ return null;
110
+ }
111
+ const now = look(env.PATH ?? '');
112
+ if (now && now.root !== made.root) write(`Installed, but another ${COMMAND} comes first on your PATH: ${now.file}\n`);
113
+ else write(`Next time, type: ${COMMAND}\n${now ? '' : offPath(path.join(target, 'bin'))}`);
114
+ return made.root;
115
+ }
116
+
117
+ export async function start({ argv = process.argv.slice(2), env = process.env, input = process.stdin, output = process.stdout } = {}) {
118
+ // npx puts node_modules/.bin of the current folder and of every folder above it first on
119
+ // PATH. Nothing is looked up or started before those entries are gone.
120
+ env.PATH = cleanPath(env.PATH);
121
+ let root = ROOT;
122
+ if (fromNpx()) {
123
+ try { root = prepare({ argv, env, interactive: Boolean(input.isTTY && output.isTTY), write: text => output.write(text) }) ?? ROOT; }
124
+ catch (error) { if (input.isTTY && output.isTTY && !argv.length) output.write(`The ${COMMAND} command was not installed (${error.message}). To install it yourself: ${BY_HAND}\n`); }
125
+ }
126
+ const entry = path.join(root, 'bin', 'elu-code.mjs');
127
+ // The app runs only as the program Node was asked to start, so it is named as that.
128
+ process.argv[1] = entry;
129
+ await import(pathToFileURL(entry).href);
130
+ }
package/lib/kanban.mjs ADDED
@@ -0,0 +1,180 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { atomic, mkdir, noSymlinks, readJson, refuse } from './util.mjs';
5
+ import { waitLock, taskTitle } from './state.mjs';
6
+ import { botWork } from './bots.mjs';
7
+
8
+ // One Kanban board per project. Cards you write wait in To do; a started card follows
9
+ // its session (In progress while the lead works, Needs you when it stops), and Done
10
+ // is where you put finished work. Sessions typed elsewhere appear on the board too.
11
+ // The board lives with the project's sessions, not in its files, so it is never
12
+ // committed by accident.
13
+
14
+ export const COLUMNS = Object.freeze([['todo', 'To do'], ['progress', 'In progress'], ['needs', 'Needs you'], ['done', 'Done']]);
15
+ const DONE_SHOWN = 20, QUIET_DAYS = 14;
16
+ const file = repo => path.join(repo.state, 'kanban.json');
17
+ const time = value => { const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : 0; };
18
+ const firstLine = value => String(value ?? '').split('\n').find(line => line.trim())?.trim() ?? '';
19
+
20
+ export function readBoard(repo) {
21
+ const target = file(repo); noSymlinks(target);
22
+ if (!fs.existsSync(target)) return { cards: [], done: [], closed: {}, archived: [] };
23
+ const value = readJson(target);
24
+ const closed = value?.closed && typeof value.closed === 'object' && !Array.isArray(value.closed) ? Object.fromEntries(Object.entries(value.closed).filter(([, at]) => typeof at === 'string')) : {};
25
+ return {
26
+ cards: Array.isArray(value?.cards) ? value.cards.filter(card => typeof card?.id === 'string' && typeof card.text === 'string' && ['todo', 'started', 'done'].includes(card.column)) : [],
27
+ done: Array.isArray(value?.done) ? value.done.filter(id => typeof id === 'string') : [],
28
+ closed,
29
+ // The sessions the owner archived: they leave the board, while a session moved to Done on
30
+ // the board stays in Done. A board saved before this list did not tell the two apart, so
31
+ // every session it closed counts as archived.
32
+ archived: Array.isArray(value?.archived) ? value.archived.filter(id => typeof id === 'string') : Object.keys(closed),
33
+ };
34
+ }
35
+ const lastActivity = task => Math.max(time(task.createdAt), ...(task.runs ?? []).map(run => time(run.at)), ...(task.messages ?? []).map(row => time(row.at)));
36
+ // An archived session (or one moved to Done) stays closed until it has newer activity: a
37
+ // new message opens it again.
38
+ export function sessionClosed(board, task) {
39
+ const at = board.done.includes(task.id) ? Date.parse(board.closed?.[task.id] ?? '') : NaN;
40
+ return Number.isFinite(at) && lastActivity(task) <= at && !['running', 'preparing'].includes(task.state);
41
+ }
42
+ export function closeSession(repo, id) {
43
+ return change(repo, board => { if (!board.done.includes(id)) board.done.push(id); if (!board.archived.includes(id)) board.archived.push(id); board.closed[id] = new Date().toISOString(); return id; });
44
+ }
45
+ // Brings an archived session back into the list.
46
+ export function reopenSession(repo, id) {
47
+ return change(repo, board => { board.done = board.done.filter(row => row !== id); board.archived = board.archived.filter(row => row !== id); delete board.closed[id]; return id; });
48
+ }
49
+ function change(repo, edit) {
50
+ mkdir(repo.state);
51
+ const release = waitLock(repo, 'kanban', 200);
52
+ try { const board = readBoard(repo), result = edit(board); atomic(file(repo), board); return result; }
53
+ finally { release(); }
54
+ }
55
+ const find = (board, id) => board.cards.find(card => card.id === id) ?? refuse('That card no longer exists.');
56
+ const checkText = text => { const value = String(text ?? '').trim(); if (!value || value.length > 16000) refuse('Write a task of 1 to 16000 characters.'); return value; };
57
+
58
+ export function addCard(repo, text) {
59
+ const value = checkText(text), now = new Date().toISOString();
60
+ return change(repo, board => { if (board.cards.length >= 500) refuse('Keep at most 500 cards.'); const card = { id: `card-${crypto.randomUUID().slice(0, 8)}`, text: value, column: 'todo', requestId: null, taskId: null, createdAt: now, movedAt: now }; board.cards.push(card); return card; });
61
+ }
62
+ export function editCard(repo, id, text) {
63
+ const value = checkText(text);
64
+ return change(repo, board => { const card = find(board, id); if (card.column !== 'todo') refuse('Only a To do task can be edited. Its session has the conversation.'); card.text = value; return card; });
65
+ }
66
+ export function removeCard(repo, id) {
67
+ return change(repo, board => { const card = find(board, id); if (card.column === 'started') refuse('Move it to Done instead. Its session stays in Sessions.'); board.cards = board.cards.filter(value => value !== card); return card; });
68
+ }
69
+ // A started card remembers the request that started it; its session number arrives later.
70
+ export function startCard(repo, id, requestId) {
71
+ return change(repo, board => { const card = find(board, id); if (card.column !== 'todo') refuse('Only a To do task can be started.'); Object.assign(card, { column: 'started', requestId, movedAt: new Date().toISOString() }); return card; });
72
+ }
73
+ export function linkCards(repo, requests) {
74
+ const board = readBoard(repo);
75
+ const missing = board.cards.filter(card => card.requestId && !card.taskId && requests.some(row => row.id === card.requestId && row.taskId));
76
+ if (!missing.length) return false;
77
+ change(repo, current => { for (const card of current.cards) { const row = card.requestId && !card.taskId && requests.find(value => value.id === card.requestId); if (row?.taskId) card.taskId = String(row.taskId); } });
78
+ return true;
79
+ }
80
+ // Done and back. A card that never started goes back to To do.
81
+ export function setDone(repo, item, done) {
82
+ return change(repo, board => {
83
+ if (item.card) { const card = find(board, item.card.id); card.column = done ? 'done' : card.requestId || card.taskId ? 'started' : 'todo'; card.movedAt = new Date().toISOString(); return card; }
84
+ board.done = board.done.filter(id => id !== item.task.id); board.archived = board.archived.filter(id => id !== item.task.id); delete board.closed[item.task.id];
85
+ if (done) { board.done.push(item.task.id); board.closed[item.task.id] = new Date().toISOString(); }
86
+ return null;
87
+ });
88
+ }
89
+
90
+ function sessionColumn(task) {
91
+ if (['running', 'preparing'].includes(task.state)) return 'progress';
92
+ // A Task that waits for the owner's yes needs them, though its session rests as an answer leaves it.
93
+ if (task.plan?.state === 'proposed') return 'needs';
94
+ if (task.pr || task.kind === 'conversation' && task.state === 'answered') return 'done';
95
+ return 'needs';
96
+ }
97
+ const requestColumn = request => ['queued', 'starting', 'running'].includes(request.state) ? 'progress' : 'needs';
98
+ function sessionDetail(task) {
99
+ if (task.state === 'running' || task.state === 'preparing') return botWork(task) ? `${botWork(task).agent} ${botWork(task).label}` : task.activeRole && task.activeRole !== 'lead' ? `${task.activeRole} reviewing` : 'working';
100
+ if (task.state === 'needs_input') return 'asks you';
101
+ if (task.state === 'ready') return 'ready to publish';
102
+ if (task.pr) return 'published';
103
+ return task.state.replace('_', ' ');
104
+ }
105
+ // A session with a Task (lib/plan.mjs) is on the board as that Task: its title, then where
106
+ // it stands with the count of what is done, its priority and its severity. Under Needs you
107
+ // what the session waits for comes first. A rejected Task leaves the session's own card.
108
+ // The title is the model's text, and the screen shows it as typed.
109
+ const taskOf = task => task?.plan && task.plan.state !== 'rejected' ? task.plan : null;
110
+ function taskStands(plan, short) {
111
+ const steps = plan.subtasks?.length ?? 0, done = (plan.subtasks ?? []).filter(row => row.state === 'done').length, met = plan.met?.length ?? 0, goals = plan.objectives?.length ?? 0;
112
+ if (plan.state === 'proposed') return short ? 'to approve' : 'waits for your approval';
113
+ if (plan.state === 'done') return short ? `done ${met}/${goals}` : `done · ${met} of ${goals} objectives reported met`;
114
+ if (!steps) return short ? 'approved' : 'approved · no subtasks yet';
115
+ return short ? `${done}/${steps}` : `approved · ${done} of ${steps} subtasks done`;
116
+ }
117
+ const taskDetail = (plan, task, column) => [column === 'needs' && plan.state !== 'proposed' ? sessionDetail(task) : '', taskStands(plan, true), plan.priority, plan.severity].filter(Boolean).join(' · ');
118
+ // The whole of it, for the row under the board while the card is selected.
119
+ function taskNote(plan, task) {
120
+ const name = taskTitle(task), doing = plan.state === 'proposed' || task.state === 'answered' ? '' : sessionDetail(task);
121
+ return [`Task · ${taskStands(plan, false)}`, `${plan.priority} priority`, `${plan.severity} severity`, name && name !== plan.title ? `session: ${name}` : '', doing].filter(Boolean).join(' · ');
122
+ }
123
+ // As much of a Task card's detail as fits beside its title: whole parts from the start,
124
+ // never half a word.
125
+ export function fitDetail(detail, room) {
126
+ let text = '';
127
+ for (const part of String(detail ?? '').split(' · ')) { const next = text ? `${text} · ${part}` : part; if (next.length > room) break; text = next; }
128
+ return text;
129
+ }
130
+ // A title cut to its room: at a word, unless that leaves more than a third of the room
131
+ // empty (a long word after a short one): then inside the word.
132
+ function cutTitle(text, room) {
133
+ if (room < 2) return text.slice(0, Math.max(0, room));
134
+ const cut = text.slice(0, room - 1), space = cut.lastIndexOf(' '), words = space > 0 ? cut.slice(0, space).trimEnd() : cut;
135
+ return `${(words.length + 1) * 3 >= room * 2 ? words : cut.trimEnd()}…`;
136
+ }
137
+ // A Task's card in the width its column has, the title first. Whole parts of the detail go
138
+ // beside the whole title as far as they fit, and a title that fits is never cut for them.
139
+ // Only a title longer than the width is cut: the first part stays beside it while the title
140
+ // keeps at least three fifths of the width, and otherwise the title has the line to itself.
141
+ export function fitCard(title, detail, width) {
142
+ const text = String(title ?? '').replace(/\s/g, ' ').trimEnd();
143
+ if (text.length <= width) return { title: text, detail: fitDetail(detail, width - text.length - 2) };
144
+ const first = String(detail ?? '').split(' · ')[0], room = width - first.length - 2;
145
+ return first && room * 5 >= width * 3 ? { title: cutTitle(text, room), detail: first } : { title: cutTitle(text, width), detail: '' };
146
+ }
147
+ // The board's four columns for one project snapshot: { key, column, title, detail, card?, task?, request?, plan?, note? }.
148
+ export function boardColumns(board, snapshot, now = Date.now()) {
149
+ // A teammate's question lives in its Chats thread; a handoff is a card like any session.
150
+ const tasks = (snapshot.tasks ?? []).filter(task => !['ask', 'review'].includes(task.origin?.kind)), queue = (snapshot.queue ?? []).filter(row => !row.origin && row.state !== 'done');
151
+ const linked = new Set(board.cards.flatMap(card => card.taskId ? [card.taskId] : [])), requested = new Set(board.cards.flatMap(card => card.requestId ? [card.requestId] : []));
152
+ const items = [];
153
+ for (const card of board.cards) {
154
+ const task = card.taskId && tasks.find(value => value.id === card.taskId), request = !task && card.requestId && queue.find(value => value.id === card.requestId);
155
+ const column = card.column === 'todo' ? 'todo' : card.column === 'done' ? 'done' : task ? sessionColumn(task) === 'done' ? 'needs' : sessionColumn(task) : request ? requestColumn(request) : 'needs';
156
+ // A card the owner wrote keeps its own words as the title; its session's Task shows beside them.
157
+ const plan = card.column === 'todo' ? null : taskOf(task);
158
+ const detail = card.column === 'todo' ? '' : plan ? taskDetail(plan, task, column) : task ? sessionDetail(task) : request ? request.state : card.column === 'done' ? 'done' : 'not started';
159
+ items.push({ key: card.id, column, title: firstLine(card.text), detail, card, task: task || null, request: request || null, ...(plan ? { plan, note: taskNote(plan, task) } : {}), order: column === 'done' ? -time(card.movedAt) : time(card.createdAt) });
160
+ }
161
+ for (const task of tasks) {
162
+ if (linked.has(task.id)) continue;
163
+ // An archived session is on no column, Done included, until a new message opens it again.
164
+ if ((board.archived ?? []).includes(task.id) && sessionClosed(board, task)) continue;
165
+ const last = Math.max(time(task.createdAt), ...(task.runs ?? []).map(run => time(run.at)), ...(task.messages ?? []).map(row => time(row.at)));
166
+ const reopened = board.done.includes(task.id) && board.closed?.[task.id] && !sessionClosed(board, task);
167
+ let column = board.done.includes(task.id) && !reopened ? 'done' : sessionColumn(task);
168
+ if (column !== 'progress' && now - last > QUIET_DAYS * 86400000 && column !== 'done') continue;
169
+ const plan = taskOf(task);
170
+ items.push({ key: `task-${task.id}`, column, title: plan ? plan.title : taskTitle(task), detail: plan ? taskDetail(plan, task, column) : sessionDetail(task), task, ...(plan ? { plan, note: taskNote(plan, task) } : {}), order: column === 'done' ? -last : last });
171
+ }
172
+ for (const request of queue) {
173
+ if (requested.has(request.id) || request.continuationTaskId) continue;
174
+ items.push({ key: request.id, column: requestColumn(request), title: firstLine(request.goal), detail: request.state === 'blocked' ? `blocked · ${firstLine(request.reason)}` : request.state, request, order: time(request.createdAt) });
175
+ }
176
+ return COLUMNS.map(([column, name]) => {
177
+ const rows = items.filter(item => item.column === column).sort((a, b) => a.order - b.order);
178
+ return { column, name, items: column === 'done' ? rows.slice(0, DONE_SHOWN) : rows };
179
+ });
180
+ }
package/lib/mcp.mjs ADDED
@@ -0,0 +1,79 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { mkdir, personalDirectory, readJson, refuse } from './util.mjs';
6
+ import { APPROVAL_WAIT_SECONDS } from './modes.mjs';
7
+
8
+ // Your own Claude Code MCP servers (claude mcp list), by name. A project's lead gets all of
9
+ // them except the ones its settings turn off, and a teammate's run never gets any.
10
+ const NAME = /^[A-Za-z0-9_.-]{1,64}$/;
11
+ const claudeFile = () => process.env.CLAUDE_CONFIG_DIR ? path.join(process.env.CLAUDE_CONFIG_DIR, '.claude.json') : path.join(os.homedir(), '.claude.json');
12
+ export function claudeServers(root) {
13
+ let value;
14
+ try { value = readJson(claudeFile()); } catch { return {}; }
15
+ const servers = { ...(value?.mcpServers ?? {}), ...(root ? value?.projects?.[root]?.mcpServers ?? {} : {}) };
16
+ return Object.fromEntries(Object.entries(servers).filter(([name, server]) => NAME.test(name) && server && typeof server === 'object'));
17
+ }
18
+ export function checkServerName(name) {
19
+ if (typeof name !== 'string' || !NAME.test(name)) refuse('Use an MCP server name from claude mcp list.');
20
+ return name;
21
+ }
22
+ // Tells the lead which servers it has, and how the owner turns on one that is off.
23
+ export function serverNote(root, on) {
24
+ const off = Object.keys(claudeServers(root)).filter(name => !on.includes(name));
25
+ return [
26
+ on.length ? `MCP servers on for you (tools named mcp__NAME__*): ${on.join(', ')}.` : '',
27
+ off.length ? `The owner's other Claude Code MCP servers are off for you: ${off.join(', ')}. When a request needs one of them, tell the owner to type /mcp on NAME in the message box, with that server's name, and then ask again.` : '',
28
+ ].filter(Boolean).join(' ');
29
+ }
30
+ // The approval relay: ELU Code's own stdio MCP server, started by Claude Code
31
+ // from the same private config file as the owner's servers, named as the permission prompt
32
+ // tool. Its arguments say which run it answers for; the state folder comes from the env.
33
+ export const APPROVAL_SERVER = 'elu-code-approval', APPROVAL_TOOL = `mcp__${APPROVAL_SERVER}__approve`;
34
+ const SERVER_SCRIPT = fileURLToPath(new URL('./approval-server.mjs', import.meta.url));
35
+ export function approvalServer({ session, root, taskId, role, vendor, log, waitSeconds = APPROVAL_WAIT_SECONDS }) {
36
+ if (typeof session !== 'string' || !/^[A-Za-z0-9_-]{1,80}$/.test(session)) refuse('An approval relay needs a plain session id.');
37
+ if (typeof log !== 'string' || !path.isAbsolute(log)) refuse('An approval relay needs an absolute log path.');
38
+ const args = [SERVER_SCRIPT, '--session', session, '--root', String(root ?? ''), '--task', String(taskId ?? ''), '--role', String(role ?? 'lead'), '--vendor', String(vendor ?? 'claude'), '--log', log, '--wait', String(waitSeconds)];
39
+ return { command: process.execPath, args, env: { HOME: os.homedir(), ...(process.env.XDG_CONFIG_HOME ? { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME } : {}) } };
40
+ }
41
+ // Server settings can hold tokens, so they go to a private file for one run, never to
42
+ // the command line. The file lives in ELU Code's own state folder, which no session may
43
+ // read; the shared temp folder is one a session can read. Its folder is named for the
44
+ // process that made it, and the caller removes it when the run ends. approval: the relay
45
+ // settings for this run (approvalServer), or null for a run that never asks.
46
+ const OWN = /^mcp-([1-9][0-9]*)-[A-Za-z0-9]{6}$/, EARLIER = /^elu-code-mcp-[A-Za-z0-9]{6}$/, EARLIER_AGE_MS = 60000;
47
+ const alive = pid => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
48
+ const entries = dir => { try { return fs.readdirSync(dir); } catch { return []; } };
49
+ const drop = folder => { try { if (fs.lstatSync(folder).isDirectory()) fs.rmSync(folder, { recursive: true, force: true }); } catch { /* Gone already. */ } };
50
+ // Removes the settings a run left behind when its process died before it could: folders
51
+ // of a process that is gone, and the folders an earlier release made in the temp folder.
52
+ // It runs when ELU Code starts and never throws.
53
+ export function sweepServers() {
54
+ const state = personalDirectory(), uid = process.getuid?.();
55
+ for (const name of entries(state)) {
56
+ const pid = Number(OWN.exec(name)?.[1] ?? 0);
57
+ if (pid && pid !== process.pid && !alive(pid)) drop(path.join(state, name));
58
+ }
59
+ const temps = [os.tmpdir()]; try { temps.push(fs.realpathSync(os.tmpdir())); } catch { /* Listed as named. */ }
60
+ for (const dir of new Set(temps)) {
61
+ for (const name of entries(dir).filter(name => EARLIER.test(name))) {
62
+ try { const stat = fs.lstatSync(path.join(dir, name)); if ((uid === undefined || stat.uid === uid) && Date.now() - stat.mtimeMs > EARLIER_AGE_MS) drop(path.join(dir, name)); } catch { /* Gone already. */ }
63
+ }
64
+ }
65
+ }
66
+ export function writeServers(names, root, { approval = null } = {}) {
67
+ const all = claudeServers(root), picked = {};
68
+ for (const name of names) {
69
+ if (name === APPROVAL_SERVER) refuse(`The MCP server name ${APPROVAL_SERVER} is reserved for ELU Code's approval relay.`);
70
+ if (!all[name]) refuse(`The MCP server ${name} is not set up in Claude Code. Run claude mcp list, or /mcp off ${name}.`);
71
+ picked[name] = all[name];
72
+ }
73
+ if (approval) picked[APPROVAL_SERVER] = approvalServer(approval);
74
+ const state = personalDirectory(); mkdir(state);
75
+ const folder = fs.mkdtempSync(path.join(state, `mcp-${process.pid}-`)); fs.chmodSync(folder, 0o700);
76
+ const file = path.join(folder, 'mcp.json');
77
+ fs.writeFileSync(file, JSON.stringify({ mcpServers: picked }), { mode: 0o600, flag: 'wx' });
78
+ return { file, folder, names: Object.keys(picked) };
79
+ }
package/lib/memory.mjs ADDED
@@ -0,0 +1,35 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ // The owner's Claude Code memory for a folder: the notes Claude Code keeps in
6
+ // ~/.claude/projects/<folder>/memory and loads into a new session there, plus the owner's
7
+ // ~/.claude/CLAUDE.md and the folder's CLAUDE.md. The lead may not read ~/.claude, so a run
8
+ // gets a private read-only copy of the memory folder, removed when the run ends.
9
+ const LINES = 200, BYTES = 25000;
10
+ const claudeDir = () => process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
11
+ export const memorySlug = folder => folder.replace(/[^A-Za-z0-9]/g, '-');
12
+ export const memoryFolder = root => path.join(claudeDir(), 'projects', memorySlug(root), 'memory');
13
+ // Claude Code loads the first 200 lines of the index.
14
+ export function clipNotes(text) {
15
+ const lines = String(text).split('\n'), kept = lines.slice(0, LINES).join('\n');
16
+ const clipped = Buffer.byteLength(kept) > BYTES ? Buffer.from(kept).subarray(0, BYTES).toString('utf8').replace(/�$/, '') : kept;
17
+ return clipped + (clipped.length < String(text).length ? '\n[Truncated.]' : '');
18
+ }
19
+ const readFile = file => { try { return fs.lstatSync(file).isFile() ? fs.readFileSync(file, 'utf8') : ''; } catch { return ''; } };
20
+ export function copyMemory(root) {
21
+ const source = memoryFolder(root), user = readFile(path.join(claudeDir(), 'CLAUDE.md')), project = readFile(path.join(root, 'CLAUDE.md'));
22
+ let names = [];
23
+ try { names = fs.readdirSync(source, { withFileTypes: true }).filter(entry => entry.isFile() && entry.name.endsWith('.md')).map(entry => entry.name); } catch { /* No memory for this folder. */ }
24
+ if (!names.length && !user.trim() && !project.trim()) return null;
25
+ let folder = null, dir = null, index = '';
26
+ if (names.length) {
27
+ folder = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'elu-code-memory-'))); fs.chmodSync(folder, 0o700);
28
+ dir = path.join(folder, 'memory'); fs.mkdirSync(dir, { mode: 0o700 });
29
+ try {
30
+ for (const name of names) fs.writeFileSync(path.join(dir, name), readFile(path.join(source, name)), { mode: 0o600, flag: 'wx' });
31
+ index = names.includes('MEMORY.md') ? fs.readFileSync(path.join(dir, 'MEMORY.md'), 'utf8') : '';
32
+ } catch (error) { fs.rmSync(folder, { recursive: true, force: true }); throw error; }
33
+ }
34
+ return { folder, dir, index, user, project };
35
+ }
package/lib/models.mjs ADDED
@@ -0,0 +1,144 @@
1
+ // A session's model: a vendor's command line and the model name it is started with. Each
2
+ // session keeps its own in its saved policy, so choosing one for a session changes no
3
+ // other. The choices are the names each installed vendor lists, plus the ones the owner
4
+ // added by name; those are kept in the personal folder (models.json), next to the bots.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { atomic, findBin, hash, personalDirectory, readJson, refuse } from './util.mjs';
8
+ import { approveSaved, trustedText } from './project-trust.mjs';
9
+ import { CAPS, VENDORS, VENDOR_NAMES } from './vendors.mjs';
10
+ import { parseConfig, validateConfig } from './config.mjs';
11
+ import { stringifyYaml } from './yaml.mjs';
12
+ import { assertPolicy } from './policy.mjs';
13
+ import { pinnedConfig } from './checks.mjs';
14
+ import { lock, readTask, saveTask, taskDir } from './state.mjs';
15
+ import { isPeerRun } from './modes.mjs';
16
+ import { botModelProblem } from './bots.mjs';
17
+ import { botModels } from './bot-models.mjs';
18
+
19
+ // What a new session runs on until the project names its own default.
20
+ export const FIRST_MODEL = Object.freeze({ vendor: 'claude', model: 'opus' });
21
+ const ADDED_MAX = 40;
22
+ // The project config takes fewer characters in a model name than a bot does.
23
+ const CONFIG_NAME = /^[A-Za-z0-9._:/-]{1,128}$/;
24
+ const SHORT = { claude: 'Claude', codex: 'Codex', agy: 'Antigravity', grok: 'Grok' };
25
+
26
+ // Vendors that can run a session, and the ones of them installed here.
27
+ export const sessionVendors = () => VENDORS.filter(vendor => CAPS[vendor]?.lead);
28
+ export const installedVendors = (env = process.env) => sessionVendors().filter(vendor => findBin(vendor, env));
29
+ // What is wrong with a model name for a session on this vendor, or null. No name is the
30
+ // vendor's own default.
31
+ export function modelProblem(vendor, name) {
32
+ if (!sessionVendors().includes(vendor)) return `A session runs on one of: ${sessionVendors().join(', ')}.`;
33
+ if (name == null || name === '') return null;
34
+ return botModelProblem(vendor, name) ?? (CONFIG_NAME.test(String(name).trim()) ? null : 'A model name here has letters, digits, dots, colons, slashes or dashes.');
35
+ }
36
+ // A model in its saved shape, { vendor, model }, or a refusal the owner can act on.
37
+ export function checkModel(value) {
38
+ const vendor = value?.vendor, model = value?.model == null || value.model === '' ? null : String(value.model).trim();
39
+ if (typeof vendor !== 'string') refuse('A model names its vendor.');
40
+ const problem = modelProblem(vendor, model); if (problem) refuse(problem);
41
+ return { vendor, model };
42
+ }
43
+ // "claude · opus": a model as a status line names it. A vendor alone is its own default.
44
+ export const modelText = choice => [choice.vendor, choice.model].filter(Boolean).join(' · ');
45
+ // What a vendor is called where a model has no name of its own.
46
+ export const vendorShort = vendor => SHORT[vendor] ?? vendor;
47
+
48
+ // The model in a config, and the one a new session of that project starts with: the one
49
+ // the project names, else the first model on Claude, else the vendor's own default.
50
+ export const configModel = config => ({ vendor: config.roles.lead.vendor, model: config.roles.lead.model ?? null });
51
+ export function defaultModel(config) {
52
+ const { vendor, model } = configModel(config);
53
+ return model || vendor !== FIRST_MODEL.vendor ? { vendor, model } : { ...FIRST_MODEL };
54
+ }
55
+ // A copy of a config that runs on this model. A dollar budget and a command list stay only
56
+ // where the vendor enforces them.
57
+ export function withModel(config, choice) {
58
+ const { vendor, model } = checkModel(choice), next = structuredClone(config), lead = next.roles.lead;
59
+ lead.vendor = vendor;
60
+ if (model) lead.model = model; else delete lead.model;
61
+ lead.maxCostUsd = CAPS[vendor].budget ? lead.maxCostUsd ?? null : null;
62
+ if (!CAPS[vendor].commands) lead.commands = [];
63
+ return next;
64
+ }
65
+
66
+ export const modelsFile = () => path.join(personalDirectory(), 'models.json');
67
+ // The models the owner added by name. A row that is no longer valid is left out.
68
+ export function addedModels() {
69
+ let value; try { value = readJson(modelsFile()); } catch { return []; }
70
+ return (Array.isArray(value?.models) ? value.models : []).filter(row => typeof row?.name === 'string' && row.name && !modelProblem(row.vendor, row.name)).map(row => ({ vendor: row.vendor, name: row.name }));
71
+ }
72
+ export function rememberModel(vendor, name) {
73
+ const { model } = checkModel({ vendor, model: name });
74
+ if (!model) refuse('Name the model to add.');
75
+ const rows = addedModels();
76
+ if (!rows.some(row => row.vendor === vendor && row.name === model)) atomic(modelsFile(), { version: 1, models: [...rows, { vendor, name: model }].slice(-ADDED_MAX) });
77
+ return { vendor, model };
78
+ }
79
+ // Every model a session can be given here, as [{ name, vendor, model, note }]. name is what
80
+ // the owner types: the model's own name, or vendor:name when two vendors share it. Claude's
81
+ // own default is its first model, so only the other vendors have a row for theirs.
82
+ export function modelChoices({ env = process.env } = {}) {
83
+ const rows = [], added = addedModels();
84
+ for (const vendor of installedVendors(env)) {
85
+ if (vendor !== FIRST_MODEL.vendor) rows.push({ vendor, model: null, note: 'its own default model' });
86
+ const listed = botModels(vendor, { env }).filter(row => !modelProblem(vendor, row.name));
87
+ for (const row of listed) rows.push({ vendor, model: row.name, note: row.note });
88
+ for (const row of added) if (row.vendor === vendor && !listed.some(item => item.name === row.name)) rows.push({ vendor, model: row.name, note: 'added by you' });
89
+ }
90
+ const plain = row => row.model ?? row.vendor, shared = name => rows.filter(row => plain(row) === name).length > 1 || VENDORS.includes(name);
91
+ return rows.map(row => ({ ...row, name: row.model && shared(row.model) ? `${row.vendor}:${row.model}` : plain(row) }));
92
+ }
93
+ // The model a typed name means: a listed name, vendor:name for any model of an installed
94
+ // vendor, or a vendor alone for its default. Throws with a line that says what to type.
95
+ export function findModel(text, { env = process.env } = {}) {
96
+ const typed = String(text ?? '').trim(), choices = modelChoices({ env }), pick = row => ({ vendor: row.vendor, model: row.model });
97
+ const listed = choices.find(row => row.name === typed) ?? choices.find(row => row.name.toLowerCase() === typed.toLowerCase());
98
+ if (listed) return pick(listed);
99
+ const at = typed.indexOf(':'), vendor = (at < 0 ? typed : typed.slice(0, at)).toLowerCase(), name = at < 0 ? '' : typed.slice(at + 1);
100
+ if (sessionVendors().includes(vendor)) {
101
+ if (!findBin(vendor, env)) throw new Error(`${VENDOR_NAMES[vendor]} is not installed or not on your PATH.`);
102
+ if (!name) return vendor === FIRST_MODEL.vendor ? { ...FIRST_MODEL } : { vendor, model: null };
103
+ const problem = modelProblem(vendor, name); if (problem) throw new Error(problem);
104
+ return { vendor, model: name };
105
+ }
106
+ const same = choices.filter(row => row.model === typed);
107
+ if (same.length > 1) throw new Error(`${typed} is on ${same.map(row => row.vendor).join(' and ')}. Type ${same.map(row => row.name).join(' or ')}.`);
108
+ throw new Error(choices.length ? `No model named ${typed.slice(0, 40)}. /model lists them; /model add adds one by name.` : 'No vendor command line is installed, so there is no model to choose yet.');
109
+ }
110
+ // The name a choice is typed as, for a list that marks the one in use.
111
+ export function choiceName(choice, choices) {
112
+ const row = choices.find(item => item.vendor === choice.vendor && item.model === choice.model);
113
+ return row?.name ?? (choice.model ? `${choice.vendor}:${choice.model}` : choice.vendor);
114
+ }
115
+
116
+ // The model a session runs on is the one in its saved policy. Only a session that keeps
117
+ // its own policy can change it; one pinned to a commit runs what that commit says.
118
+ export const ownsPolicy = task => task?.mode === 'automatic' || task?.kind === 'conversation';
119
+ // Sets a session's model from its next message. A turn in progress holds the session, so
120
+ // the change waits for it to end.
121
+ export function setSessionModel(repo, taskId, choice) {
122
+ let release;
123
+ try { release = lock(repo, `task-${taskId}`); }
124
+ catch (error) { if (error.message.includes('holds this lock')) refuse('Wait for the reply, or /stop, then choose the model.'); throw error; }
125
+ try {
126
+ const task = readTask(repo, taskId);
127
+ if (isPeerRun(task.origin)) refuse("A teammate's question keeps the model it started with.");
128
+ if (!ownsPolicy(task)) refuse('This session keeps the model of the commit it started from.');
129
+ const { config } = pinnedConfig(repo, task.base, { current: false, task });
130
+ const next = withModel(config, choice), text = stringifyYaml(next); parseConfig(text);
131
+ assertPolicy(next.roles.lead, { mode: 'ask' });
132
+ atomic(path.join(taskDir(repo, task.id), 'policy.yaml'), text); task.configHash = hash(text); saveTask(repo, task);
133
+ return task;
134
+ } finally { release(); }
135
+ }
136
+ // Sets the model new sessions of a project start with: the vendor and model in its config
137
+ // file. proposed is the config a project with no file yet would be given.
138
+ export function setDefaultModel(repo, choice, proposed) {
139
+ // The change starts from settings the owner approved, never from a file he has not.
140
+ const file = path.join(repo.root, '.elu-code/config.yaml'), approved = trustedText(repo.root, 'config.yaml');
141
+ const config = withModel(approved != null ? parseConfig(approved) : proposed, choice);
142
+ validateConfig(config); assertPolicy(config.roles.lead, { mode: 'ask' }); const text = stringifyYaml(config);
143
+ fs.mkdirSync(path.dirname(file), { recursive: true }); approveSaved(repo.root, 'config.yaml', text); atomic(file, text); return config;
144
+ }