@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
package/lib/checks.mjs ADDED
@@ -0,0 +1,102 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { baseFile, taskChanges, head, tracked, assertTaskGitSafe } from './git.mjs';
5
+ import { parseConfig } from './config.mjs';
6
+ import { protectedFiles, protectedPath, sensitive, pathAllowed } from './policy.mjs';
7
+ import { hash, noSymlinks, refuse } from './util.mjs';
8
+ import { sandboxRun } from './sandbox.mjs';
9
+ import { taskDir, saveTask, event } from './state.mjs';
10
+ import { MODES } from './modes.mjs';
11
+ import { NOT_APPROVED, trustedText } from './project-trust.mjs';
12
+
13
+ // The session's mode decides which structural rules apply. A task without
14
+ // permissions is from before modes existed and keeps every rule.
15
+ const modeOf = task => MODES.includes(task?.permissions) ? task.permissions : 'locked';
16
+
17
+ export function pinnedConfig(repo, base, { current = true, task } = {}) {
18
+ if (task?.mode === 'automatic' || task?.kind === 'conversation') {
19
+ const file = path.join(taskDir(repo, task.id), 'policy.yaml'); noSymlinks(file);
20
+ const text = fs.readFileSync(file, 'utf8');
21
+ if (hash(text) !== task.configHash) refuse('The saved task policy changed.');
22
+ return { config: parseConfig(text), configHash: hash(text) };
23
+ }
24
+ const text = baseFile(repo.root, base, '.elu-code/config.yaml');
25
+ const file = path.join(repo.root, '.elu-code', 'config.yaml'); noSymlinks(file);
26
+ if (current && text !== fs.readFileSync(file, 'utf8')) refuse('Commit policy changes before running a task.');
27
+ // A new task takes its policy from the project's file only as the owner approved it on
28
+ // this machine. Nobody is asked here, so it is refused with what to do.
29
+ if (current && trustedText(repo.root, 'config.yaml') !== text) refuse(NOT_APPROVED);
30
+ return { config: parseConfig(text), configHash: hash(text) };
31
+ }
32
+ export function structuralChecks(task, config, { mode = 'locked' } = {}) {
33
+ const change = taskChanges(task);
34
+ const results = [], full = mode === 'full', dependenciesOpen = ['auto', 'full'].includes(mode);
35
+ const add = (name, pass, details = []) => results.push({ name, status: pass ? 'passed' : 'failed', details });
36
+ add('no-ignored-output', !change.ignored.length, change.ignored);
37
+ if (!full) add('changed-lines', change.lines <= config.limits.changedLines && !change.binary, change.binary ? ['Binary changes require manual review.'] : [`${change.lines} changed lines; limit ${config.limits.changedLines}.`]);
38
+ const undeclared = change.paths.filter((file) => !pathAllowed(file, config.paths));
39
+ add('declared-paths', !undeclared.length, undeclared);
40
+ const protectedChanges = change.paths.filter(file=>protectedPath(file, { mode })||(task.hookProtectedPaths??[]).some(dir=>file===dir||file.startsWith(`${dir}/`)));
41
+ if (!full) add('protected-paths', !protectedChanges.length, protectedChanges);
42
+ const secrets = change.paths.filter(sensitive);
43
+ add('secret-paths', !secrets.length, secrets);
44
+ const deletedTests = tracked(task.worktree, task.base).filter((file) => /(^|\/)(?:tests?|__tests__)(\/|$)|\.(?:test|spec)\.[^/]+$/.test(file) && !fs.existsSync(path.join(task.worktree, file)));
45
+ add('no-deleted-tests', !deletedTests.length, deletedTests);
46
+ const packages = change.paths.filter((file) => /(^|\/)package\.json$/.test(file));
47
+ const scripts = []; const dependencies = [];
48
+ for (const file of packages) {
49
+ const before = JSON.parse(baseFile(task.worktree, task.base, file, { optional: true }) ?? '{}');
50
+ const full = path.join(task.worktree, file); noSymlinks(full);
51
+ const after = fs.existsSync(full) ? JSON.parse(fs.readFileSync(full, 'utf8')) : {};
52
+ if (JSON.stringify(before.scripts ?? {}) !== JSON.stringify(after.scripts ?? {})) scripts.push(file);
53
+ for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies', 'bundledDependencies', 'bundleDependencies', 'workspaces', 'packageManager']) {
54
+ if (JSON.stringify(before[field] ?? {}) !== JSON.stringify(after[field] ?? {})) dependencies.push(`${file}: ${field}`);
55
+ }
56
+ }
57
+ if (!full) add('unchanged-scripts', !scripts.length, scripts);
58
+ if (!dependenciesOpen) add('no-dependency-changes', !dependencies.length, dependencies);
59
+ return { change, results };
60
+ }
61
+ export async function runChecks(repo, task, { signal } = {}) {
62
+ if (task.kind === 'conversation') refuse('This response has no code changes to verify.');
63
+ signal?.throwIfAborted();
64
+ const { config, configHash } = pinnedConfig(repo, task.base, { current: false, task });
65
+ const hooks=assertTaskGitSafe(repo,task);
66
+ if (task.configHash !== configHash) refuse('The pinned task policy changed.');
67
+ if (head(task.worktree) !== (task.commit ?? task.base)) refuse('The task worktree HEAD changed outside ELU Code.');
68
+ const mode = modeOf(task);
69
+ const { change, results } = structuralChecks(task, config, { mode });
70
+ const protectedPaths = [...protectedFiles(tracked(task.worktree, task.base), { mode }),...hooks.protectedPaths];
71
+ if (results.every((row) => row.status === 'passed')) {
72
+ for (const check of config.checks) {
73
+ const logFile = path.join(taskDir(repo, task.id), `check-${check.name}-${crypto.randomUUID()}.log`);
74
+ const scratch = path.join(task.worktree, '.elu-code', 'tmp',crypto.randomUUID()); noSymlinks(scratch);
75
+ let run;
76
+ task.activeRole = 'checks'; task.activeLog = logFile; task.activeSince = new Date().toISOString(); saveTask(repo, task);
77
+ event(repo, 'check-started', { taskId: task.id, name: check.name });
78
+ try {
79
+ run = await sandboxRun({ cwd: task.worktree, protectedPaths, scratch, program: check.command[0], args: check.command.slice(1), logFile, processFile: path.join(taskDir(repo, task.id), 'process.json'), timeoutSeconds: config.roles.lead.timeoutSeconds,config,signal, network: mode === 'full' ? 'on' : 'off' });
80
+ } finally {
81
+ if (fs.existsSync(scratch)) fs.rmSync(scratch, { recursive: true });
82
+ task.activeLog = null; task.activeRole = null; saveTask(repo, task);
83
+ }
84
+ results.push({ name: check.name, status: run.code === 0 && !run.timedOut ? 'passed' : 'failed', logFile, ...(run.timedOut ? { details: ['Timed out.'] } : {}) });
85
+ event(repo, 'check-finished', { taskId: task.id, name: check.name, status: results.at(-1).status });
86
+ const after = taskChanges(task);
87
+ if (after.digest !== change.digest) {
88
+ results.push({ name: 'checks-left-source-unchanged', status: 'failed', details: ['A check changed the source tree.'] }); break;
89
+ }
90
+ }
91
+ } else {
92
+ for (const check of config.checks) results.push({ name: check.name, status: 'skipped', details: ['Fix structural failures first.'] });
93
+ }
94
+ if (task.mode === 'automatic' && !config.checks.length) results.push({ name: 'project-checks', status: 'not-configured', details: ['No test or build command is configured. Edits have not been verified.'] });
95
+ task.evidence = { base: task.base, digest: taskChanges(task).digest, changedLines: change.lines, results, checkedAt: new Date().toISOString() };
96
+ saveTask(repo, task); event(repo, 'checked', { taskId: task.id, passed: results.every((row) => row.status === 'passed') });
97
+ return task.evidence;
98
+ }
99
+ export function evidenceCurrent(task) {
100
+ if (!task.evidence || task.evidence.base !== task.base || !task.evidence.results.every((row) => row.status === 'passed')) return false;
101
+ return taskChanges(task).digest === task.evidence.digest;
102
+ }
@@ -0,0 +1,337 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { spawn } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { atomic, mkdir, noSymlinks, readJson, refuse, exactKeys } from './util.mjs';
8
+ import { lock, readTask, saveTask, tasks, taskDir } from './state.mjs';
9
+ import { processIdentity, recoverProcess } from './process.mjs';
10
+ import { personalDirectory } from './usage.mjs';
11
+ import { ProjectController, registeredProjects } from './projects.mjs';
12
+ import { pendingRequests, saveRequest, updateRequest, removeRequest, joinRequests } from './workspace.mjs';
13
+ import { isPeerRun } from './runner.mjs';
14
+ import { checkFolders, checkMode, checkPlace } from './modes.mjs';
15
+ import { peerFile } from './peer/paths.mjs';
16
+ import { peerSettings } from './peer/settings.mjs';
17
+ import { sanitize } from './peer/peerdata.mjs';
18
+ import { refreshAwake } from './awake.mjs';
19
+ import { pendingApprovals } from './approvals.mjs';
20
+ import { BOT_NAME, chatBots, needBot } from './bots.mjs';
21
+ import { checkModel } from './models.mjs';
22
+
23
+ // The conductor is one background process per user. It owns every agent run, so work
24
+ // continues after a terminal closes. Terminals talk to it only through files in the
25
+ // personal directory: queued requests, stop commands, and the status it publishes.
26
+
27
+ export const DEFAULT_SETTINGS = Object.freeze({ maxAgents: 4, stallMinutes: 8, retries: 1 });
28
+ const PROJECT_LIMIT = 4;
29
+ const folder = () => path.join(personalDirectory(), 'conductor');
30
+ const commandFolder = () => path.join(folder(), 'commands');
31
+ const statusFile = () => path.join(folder(), 'state.json');
32
+ const lockFile = () => path.join(personalDirectory(), 'conductor.lock');
33
+ const key = (root, id) => `${root}\0${id}`;
34
+
35
+ export function conductorSettings() {
36
+ let value = {};
37
+ try { const file = path.join(folder(), 'settings.json'); noSymlinks(file); if (fs.existsSync(file)) value = readJson(file); } catch { /* Defaults apply. */ }
38
+ const pick = (name, min, max) => Number.isInteger(value?.[name]) && value[name] >= min && value[name] <= max ? value[name] : DEFAULT_SETTINGS[name];
39
+ return { maxAgents: pick('maxAgents', 1, 16), stallMinutes: pick('stallMinutes', 1, 120), retries: pick('retries', 0, 5) };
40
+ }
41
+ export function saveConductorSettings(update) {
42
+ const next = { ...conductorSettings(), ...update };
43
+ if (!Number.isInteger(next.maxAgents) || next.maxAgents < 1 || next.maxAgents > 16) refuse('Choose 1 to 16 sessions at once.');
44
+ mkdir(folder()); atomic(path.join(folder(), 'settings.json'), next); return conductorSettings();
45
+ }
46
+ // Views read this on every redraw, so it never starts a process. A conductor counts
47
+ // as running only while its heartbeat is fresh.
48
+ export function conductorStatus(now = Date.now()) {
49
+ let value = null;
50
+ try { const file = statusFile(); noSymlinks(file); if (fs.existsSync(file)) value = readJson(file); } catch { value = null; }
51
+ const running = Boolean(value?.pid && !value.stopped && Date.parse(value.heartbeatAt) > now - 15000 && alive(value.pid));
52
+ return { running, pid: running ? value.pid : null, runs: running ? value.runs ?? [] : [], settings: conductorSettings(), error: running ? value.error ?? null : null };
53
+ }
54
+ // A signal-0 check costs no process start, so a conductor killed outright reads as off at once.
55
+ function alive(pid) { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } }
56
+ export function conductorAlive() {
57
+ try {
58
+ const file = lockFile(); noSymlinks(file); if (!fs.existsSync(file)) return false;
59
+ const record = readJson(file); return Boolean(record.identity) && processIdentity(record.pid) === record.identity;
60
+ } catch { return false; }
61
+ }
62
+ // Starts the background conductor unless one is already running.
63
+ export function startConductor() {
64
+ if (conductorAlive()) return false;
65
+ mkdir(folder());
66
+ const log = path.join(folder(), 'conductor.log'); noSymlinks(log);
67
+ if (fs.existsSync(log) && fs.statSync(log).size > 1024 * 1024) fs.truncateSync(log, 0);
68
+ const out = fs.openSync(log, 'a', 0o600);
69
+ try {
70
+ const bin = fileURLToPath(new URL('../bin/elu-code.mjs', import.meta.url));
71
+ spawn(process.execPath, [bin, 'conductor', '--daemon'], { cwd: os.homedir(), env: process.env, detached: true, stdio: ['ignore', out, out] }).unref();
72
+ } finally { fs.closeSync(out); }
73
+ return true;
74
+ }
75
+ export function sendCommand(command) {
76
+ if (!['stop', 'shutdown'].includes(command?.type)) refuse('Unknown conductor command.');
77
+ if (command.type === 'stop' && (typeof command.root !== 'string' || !/^[1-9][0-9]*$/.test(String(command.taskId)))) refuse('Choose a running task to stop.');
78
+ mkdir(commandFolder());
79
+ atomic(path.join(commandFolder(), `${Date.now()}-${crypto.randomUUID()}.json`), { ...command, taskId: command.taskId == null ? undefined : String(command.taskId), at: new Date().toISOString() });
80
+ }
81
+ // The peer daemon passes what another machine sent, so every field is checked here.
82
+ const ID = /^[A-Za-z0-9_-]{1,128}$/;
83
+ export function peerOrigin(value) {
84
+ exactKeys(value, ['kind', 'peer', 'name', 'a2aTaskId', 'threadId', 'hop', 'bot'], 'Request origin');
85
+ if (!['ask', 'review', 'handoff', 'answer'].includes(value.kind)) refuse('Unknown peer request kind.');
86
+ if (typeof value.peer !== 'string' || !/^[0-9a-f]{64}$/.test(value.peer)) refuse('A peer request needs the sender key.');
87
+ if (typeof value.name !== 'string' || !value.name.trim() || value.name.length > 64 || sanitize(value.name, 64) !== value.name || /[\n\t"<>]/.test(value.name)) refuse('A peer request needs a plain sender name.');
88
+ for (const field of ['a2aTaskId', 'threadId']) if (value[field] != null && (typeof value[field] !== 'string' || !ID.test(value[field]))) refuse('A peer request has an invalid task or thread ID.');
89
+ if (!Number.isInteger(value.hop) || value.hop < 0 || value.hop > 16) refuse('A peer request has an invalid hop count.');
90
+ // bot: the owner's shared bot the teammate wrote to. It answers and never edits, so a handoff names none.
91
+ if (value.bot != null && (typeof value.bot !== 'string' || !BOT_NAME.test(value.bot) || value.kind === 'handoff')) refuse('A peer request names an invalid bot.');
92
+ return { kind: value.kind, peer: value.peer, name: value.name, a2aTaskId: value.a2aTaskId ?? null, threadId: value.threadId ?? null, hop: value.hop, ...(value.bot != null ? { bot: value.bot } : {}) };
93
+ }
94
+ // Lower priority numbers start first: Chat messages use 0, queued goals 1. A waiting
95
+ // request starts only after the owner accepts it (retryRequest). permissions, place and
96
+ // folders are the owner's session choices; a teammate's question never has them.
97
+ // bots: the bots in the session's chat. bot: the bot this message is written to, which
98
+ // answers it instead of the lead. Both name saved bots, and neither goes with a teammate's request.
99
+ // model: the model a new session runs on, { vendor, model }; without one it starts with the project's default.
100
+ // scheduled: the bot's own schedule wrote this message to it, in a session it is already in.
101
+ // botChat: the message starts the bot's own chat, the session its later messages in Chats continue.
102
+ // approved: the owner approved the session's Task; its work starts with no second planning turn.
103
+ export function enqueue(root, goal, { id, continuationTaskId = null, priority = 0, state = 'queued', origin, readOnly, context, scheduleId, permissions, place, folders, bots, bot, model, scheduled, botChat, approved } = {}) {
104
+ if (!['queued', 'waiting'].includes(state)) refuse('A new request is queued or waiting.');
105
+ if (readOnly !== undefined && typeof readOnly !== 'boolean') refuse('readOnly must be true or false.');
106
+ const peer = origin == null ? null : peerOrigin(origin);
107
+ if (!peer && context != null) refuse('Peer data needs a request origin.');
108
+ if (scheduleId !== undefined && (typeof scheduleId !== 'string' || !/^schedule-[a-f0-9]{8}$/.test(scheduleId))) refuse('Invalid schedule ID.');
109
+ const session = {};
110
+ if (permissions !== undefined) session.permissions = checkMode(permissions);
111
+ if (place !== undefined) session.place = checkPlace(place, { mode: session.permissions });
112
+ if (folders !== undefined) { const dirs = checkFolders(folders); if (dirs.length) session.folders = dirs; }
113
+ if (model != null) session.model = checkModel(model);
114
+ if (scheduled !== undefined && scheduled !== true || botChat !== undefined && botChat !== true) refuse('scheduled and botChat are true or left out.');
115
+ if (scheduled && (bot == null || continuationTaskId == null)) refuse("A bot's scheduled turn names the bot and its session.");
116
+ if (botChat && (bot == null || continuationTaskId != null)) refuse("A bot's own chat starts with a message to that bot.");
117
+ if (approved !== undefined && approved !== true || approved && (continuationTaskId == null || origin != null || scheduled || scheduleId || readOnly)) refuse('Only the owner approves a Task, in its own session.');
118
+ const chat = { ...(bots !== undefined ? { bots: chatBots(bots) } : {}), ...(bot != null ? { bot: needBot(bot).name } : {}), ...(scheduled ? { scheduled: true } : {}), ...(botChat ? { botChat: true } : {}), ...(approved ? { approved: true } : {}) };
119
+ if (peer && Object.keys(chat).length) refuse("Bots join your own sessions, not a teammate's question.");
120
+ let fields = { ...(readOnly ? { readOnly: true } : {}), ...(scheduleId ? { scheduleId } : {}), ...session, ...chat };
121
+ if (peer) {
122
+ const limit = peer.kind === 'review' ? 160000 : 16000;
123
+ if (typeof context !== 'string' || !context.trim() || context.length > limit) refuse(`Peer data must be 1 to ${limit} characters.`);
124
+ if (isPeerRun(peer) && readOnly !== true) refuse('Asks and reviews from a teammate must be read-only.');
125
+ if (isPeerRun(peer) && Object.keys(session).length) refuse('Teammate questions stay read-only.');
126
+ // A handoff edits the owner's project, so only the owner can start it: it waits until
127
+ // the owner accepts it, or is queued when the owner already chose a mode.
128
+ if (peer.kind === 'handoff') { readOnly = false; if (!session.permissions) state = 'waiting'; }
129
+ fields = { origin: peer, readOnly: readOnly === true, context, ...session };
130
+ }
131
+ const reason = state === 'waiting' ? peer ? `${peer.kind} from @${peer.name}` : 'Ready to review and start.' : 'Waiting to start.';
132
+ return saveRequest(root, goal, id, { state, priority, continuationTaskId: continuationTaskId == null ? null : String(continuationTaskId), taskId: null, attempts: 0, notBefore: null, queuedAt: new Date().toISOString(), reason, ...fields });
133
+ }
134
+ // The peer daemon reads these and answers the teammate. The request ID becomes a file name.
135
+ function writeOutbox(root, row, { taskId = null, outcome, text = null, costUsd = null, error = null, finishedAt = new Date().toISOString() }) {
136
+ if (!/^request-[a-f0-9-]+$/.test(row.id)) refuse('Invalid request ID.');
137
+ atomic(peerFile('outbox', `${row.id}.json`), { requestId: row.id, root, origin: row.origin, taskId, outcome, text, costUsd, finishedAt, error });
138
+ }
139
+ export function retryRequest(root, id) {
140
+ return updateRequest(root, id, { state: 'queued', attempts: 0, notBefore: null, queuedAt: new Date().toISOString(), reason: 'Waiting to start.' }, { from: ['blocked', 'waiting'] });
141
+ }
142
+
143
+ export class Conductor {
144
+ constructor({ tickMs = 500, idleMs = 10 * 60000, stallMs, onExit } = {}) {
145
+ Object.assign(this, { tickMs, idleMs, stallMs, onExit });
146
+ this.runs = new Map(); this.controllers = new Map(); this.idleSince = Date.now(); this.queued = 0; this.error = null;
147
+ }
148
+ start() {
149
+ this.release = lock({ state: personalDirectory() }, 'conductor');
150
+ this.startedAt = new Date().toISOString(); mkdir(commandFolder());
151
+ // Commands written before this conductor held the lock were for an earlier one (a stop
152
+ // sent while none ran); obeyed now, they would end this conductor and its first run.
153
+ for (const name of fs.readdirSync(commandFolder())) fs.rmSync(path.join(commandFolder(), name), { force: true });
154
+ try { this.recover(); } catch (error) { this.error = error.message; }
155
+ this.timer = setInterval(() => this.tick(), this.tickMs); this.tick();
156
+ return this;
157
+ }
158
+ async stop() {
159
+ if (this.closed) return; this.closed = true; clearInterval(this.timer);
160
+ const runs = [...this.runs.values()];
161
+ for (const run of runs) run.stopped = true;
162
+ await Promise.all(runs.map(run => run.controller.stop(run.taskId, 'ELU Code stopped its conductor. Retry to continue.').catch(() => {})));
163
+ await Promise.all(runs.map(run => run.done));
164
+ try { this.write(true); } catch { /* Status is best effort. */ }
165
+ refreshAwake({ busy: false });
166
+ try { this.release?.(); } catch { /* The lock file is already gone. */ }
167
+ }
168
+ tick() {
169
+ if (this.busy || this.closed) return; this.busy = true;
170
+ try { this.commands(); if (this.closed) return; this.watch(); this.dispatch(); this.error = null; }
171
+ catch (error) { this.error = error.message; }
172
+ finally { this.busy = false; }
173
+ try { this.write(); } catch { /* Status is best effort. */ }
174
+ // Keeps the computer awake while agents run: at once when work starts or ends, else every 30 s.
175
+ const busy = this.runs.size > 0 || this.queued > 0;
176
+ if (!this.closed && (busy !== this.awake || Date.now() - (this.awakeAt ?? 0) > 30000)) { this.awake = busy; this.awakeAt = Date.now(); refreshAwake({ busy }); }
177
+ if (!this.closed && !this.runs.size && !this.queued && Date.now() - this.idleSince > this.idleMs) this.onExit?.();
178
+ }
179
+ commands() {
180
+ const names = fs.readdirSync(commandFolder()).filter(name => name.endsWith('.json')).sort();
181
+ for (const name of names) {
182
+ const file = path.join(commandFolder(), name); let command = null;
183
+ try { noSymlinks(file); command = readJson(file); } catch { /* A malformed command is dropped. */ }
184
+ fs.rmSync(file, { force: true });
185
+ if (command?.type === 'shutdown') { if (this.onExit) this.onExit(); else this.stop(); return; }
186
+ if (command?.type === 'stop') {
187
+ const run = this.runs.get(key(command.root, String(command.taskId)));
188
+ if (run) { run.stopped = true; run.controller.stop(run.taskId).catch(() => {}); }
189
+ }
190
+ }
191
+ }
192
+ // A run whose agent log has not grown for the stall time is stopped and retried. A run
193
+ // waiting for the owner's approval is quiet on purpose, so it is not stalled.
194
+ watch() {
195
+ const now = Date.now(), limit = this.stallMs ?? conductorSettings().stallMinutes * 60000;
196
+ for (const run of this.runs.values()) {
197
+ if (run.stalled || run.stopped) continue;
198
+ let task; try { task = readTask(run.controller.repo, run.taskId); } catch { continue; }
199
+ let size = null; try { if (task.activeLog) size = fs.statSync(task.activeLog).size; } catch { /* Not started yet. */ }
200
+ const mark = `${task.activeLog}:${size}`;
201
+ let waiting = false; try { waiting = pendingApprovals({ root: run.controller.repo.project ?? run.root, taskId: run.taskId }).length > 0; } catch { /* Unreadable: counted as quiet. */ }
202
+ if (!task.activeLog || mark !== run.mark || waiting) { run.mark = mark; run.quietSince = now; continue; }
203
+ if (now - run.quietSince > limit) {
204
+ run.stalled = true;
205
+ run.controller.stop(run.taskId, `No progress for ${Math.round(limit / 60000) || 1} minutes. Retrying.`).catch(() => {});
206
+ }
207
+ }
208
+ }
209
+ dispatch() {
210
+ const now = Date.now(), { maxAgents } = conductorSettings(), waiting = [];
211
+ for (const repo of registeredProjects()) {
212
+ let rows; try { rows = pendingRequests(repo.root); } catch { continue; }
213
+ for (const row of rows) {
214
+ if (row.state === 'queued') waiting.push({ repo, row });
215
+ if (row.state === 'done' && !(Date.parse(row.finishedAt) > now - 120000)) { try { removeRequest(repo.root, row.id); } catch { /* Next tick. */ } }
216
+ }
217
+ }
218
+ this.queued = waiting.length;
219
+ if (this.runs.size || waiting.length) this.idleSince = now;
220
+ const ready = waiting.filter(({ row }) => !(Date.parse(row.notBefore) > now))
221
+ .sort((a, b) => (a.row.priority ?? 1) - (b.row.priority ?? 1) || Date.parse(a.row.queuedAt ?? a.row.createdAt) - Date.parse(b.row.queuedAt ?? b.row.createdAt));
222
+ const slots = peerSettings().peerSlots, peerRuns = () => [...this.runs.values()].filter(run => isPeerRun(run.origin)).length;
223
+ for (const { repo, row } of ready) {
224
+ if (this.runs.size >= maxAgents) break;
225
+ if (isPeerRun(row.origin) && peerRuns() >= (Number.isInteger(slots) ? slots : 1)) continue;
226
+ const mine = [...this.runs.values()].filter(run => run.root === repo.root);
227
+ if (mine.length >= PROJECT_LIMIT) continue;
228
+ // A follow-up waits until the current turn of the same chat ends.
229
+ if (row.continuationTaskId && mine.some(run => run.taskId === row.continuationTaskId)) continue;
230
+ const turn = this.together(repo, row, ready);
231
+ if (turn) this.launch(repo, turn);
232
+ }
233
+ }
234
+ // What the owner sent to a session while it worked goes as one turn: the waiting messages
235
+ // that follow this one, to the same recipient and in the order sent, are joined to it. A
236
+ // message to someone else ends the join, so nothing overtakes it. A bot's scheduled turn,
237
+ // a teammate's message, a retry and a message that changes the session's choices each
238
+ // stay a turn of their own. Returns the request to start, or null when it was taken back.
239
+ together(repo, row, ready) {
240
+ const typed = value => Boolean(value.continuationTaskId) && !value.origin && !value.scheduled && !value.scheduleId && !value.botChat && !value.attempts && !value.readOnly
241
+ && ['permissions', 'place', 'folders', 'bots', 'model'].every(name => value[name] === undefined);
242
+ if (!typed(row)) return row;
243
+ const after = ready.filter(value => value.repo.root === repo.root && value.row.continuationTaskId === row.continuationTaskId).map(value => value.row);
244
+ const ids = [row.id];
245
+ for (const next of after.slice(after.indexOf(row) + 1)) { if (!typed(next) || (next.bot ?? null) !== (row.bot ?? null) || (next.priority ?? 1) !== (row.priority ?? 1)) break; ids.push(next.id); }
246
+ return ids.length > 1 ? joinRequests(repo.root, ids) : row;
247
+ }
248
+ controller(repo) {
249
+ let controller = this.controllers.get(repo.root);
250
+ if (!controller) { controller = new ProjectController(repo); this.controllers.set(repo.root, controller); }
251
+ else controller.reloadConfig();
252
+ return controller;
253
+ }
254
+ launch(repo, row) {
255
+ if (!updateRequest(repo.root, row.id, { state: 'starting', reason: 'Starting.' }, { from: ['queued'] })) return;
256
+ let controller, run;
257
+ try { controller = this.controller(repo); run = controller.ask(row.goal, row.continuationTaskId ?? null, { readOnly: row.readOnly === true, origin: row.origin ?? null, context: row.context ?? null, permissions: row.permissions, place: row.place, folders: row.folders, bots: row.bots, bot: row.bot ?? null, scheduled: row.scheduled === true, botChat: row.botChat === true, timed: Boolean(row.scheduleId), approved: row.approved === true, ...(row.model ? { model: row.model } : {}) }); }
258
+ catch (error) {
259
+ const busy = /At most four tasks|already running|holds this lock/.test(error.message);
260
+ if (busy) updateRequest(repo.root, row.id, { state: 'queued', notBefore: new Date(Date.now() + 5000).toISOString(), reason: 'Waiting for a free slot in this project.' });
261
+ // A teammate cannot retry a blocked request here, so they are told it failed.
262
+ else if (row.origin) { try { writeOutbox(repo.root, row, { outcome: 'failed', error: error.message }); } catch (failure) { this.error = failure.message; } updateRequest(repo.root, row.id, { state: 'done', finishedAt: new Date().toISOString(), reason: error.message }); }
263
+ // Nobody is there to retry a turn a bot's schedule queued: it ends, and the next run comes at its time.
264
+ else if (row.scheduled) updateRequest(repo.root, row.id, { state: 'done', finishedAt: new Date().toISOString(), reason: error.message });
265
+ else updateRequest(repo.root, row.id, { state: 'blocked', reason: error.message });
266
+ return;
267
+ }
268
+ let runsBefore = 0; try { runsBefore = readTask(controller.repo, run.id).runs.length; } catch { /* Counted from the start. */ }
269
+ const record = { root: repo.root, project: path.basename(repo.root), requestId: row.id, taskId: run.id, goal: row.goal, origin: row.origin ?? null, scheduled: row.scheduled === true, runsBefore, attempts: row.attempts ?? 0, controller, startedAt: new Date().toISOString(), quietSince: Date.now(), mark: null };
270
+ this.runs.set(key(repo.root, run.id), record);
271
+ updateRequest(repo.root, row.id, { state: 'running', taskId: run.id, startedAt: record.startedAt, reason: 'Working.' });
272
+ record.done = run.finished.then(task => this.finish(record, null, task), error => this.finish(record, error));
273
+ }
274
+ finish(record, error) {
275
+ this.runs.delete(key(record.root, record.taskId));
276
+ const finishedAt = new Date().toISOString();
277
+ const done = () => {
278
+ if (record.origin) { try { this.outbox(record, error, finishedAt); } catch (failure) { this.error = failure.message; } }
279
+ return updateRequest(record.root, record.requestId, { state: 'done', taskId: record.taskId, finishedAt, reason: error ? error.message : 'Finished.' });
280
+ };
281
+ try {
282
+ if (!error || record.stopped) return void done();
283
+ const retries = conductorSettings().retries, cause = error.details?.cause;
284
+ if ((record.stalled || cause === 'rate-limit') && record.attempts < retries) {
285
+ const wait = record.stalled ? 2000 : 60000 * 2 ** record.attempts;
286
+ updateRequest(record.root, record.requestId, { state: 'queued', attempts: record.attempts + 1, continuationTaskId: record.taskId, taskId: null, notBefore: new Date(Date.now() + wait).toISOString(), reason: record.stalled ? 'It stopped making progress. Retrying.' : 'Rate limited. Retrying shortly.' });
287
+ return;
288
+ }
289
+ // A failure after the message was recorded is shown in the task itself.
290
+ let recorded = null; try { recorded = readTask(record.controller.repo, record.taskId); } catch { /* Never created. */ }
291
+ if (recorded && (recorded.goal === record.goal || recorded.messages?.some(row => row.text === record.goal)) || record.origin || record.scheduled) return void done();
292
+ updateRequest(record.root, record.requestId, { state: 'blocked', taskId: null, reason: error.message });
293
+ } catch (failure) { this.error = failure.message; }
294
+ }
295
+ // The reply for a teammate is written before its request is marked done.
296
+ outbox(record, error, finishedAt) {
297
+ let task = null; try { task = readTask(record.controller.repo, record.taskId); } catch { /* Never created. */ }
298
+ const reply = error ? null : task?.kind === 'conversation' ? task.result : task?.reply ?? task?.result;
299
+ const runs = task?.runs.slice(record.runsBefore) ?? [];
300
+ const costUsd = runs.length && runs.every(run => Number.isFinite(run.costUsd)) ? runs.reduce((total, run) => total + run.costUsd, 0) : null;
301
+ const text = reply?.summary ? reply.outcome === 'needs_input' && reply.question ? `${reply.summary}\n\n${reply.question}` : reply.summary : null;
302
+ writeOutbox(record.root, { id: record.requestId, origin: record.origin }, { taskId: record.taskId, outcome: error ? record.stopped ? 'stopped' : 'failed' : reply?.outcome ?? 'failed', text, costUsd, error: error?.message ?? null, finishedAt });
303
+ }
304
+ // Runs left by a conductor that ended without stopping them are marked stopped.
305
+ // Runs owned by a live terminal are left alone.
306
+ recover() {
307
+ for (const repo of registeredProjects()) {
308
+ let values = []; try { values = tasks(repo); } catch { continue; }
309
+ for (const task of values.filter(task => ['running', 'preparing'].includes(task.state))) {
310
+ if (task.owner?.identity && processIdentity(task.owner.pid) === task.owner.identity) continue;
311
+ let release; try { release = lock(repo, `task-${task.id}`); } catch { continue; }
312
+ try {
313
+ const file = path.join(taskDir(repo, task.id), 'process.json'); noSymlinks(file);
314
+ if (fs.existsSync(file)) recoverProcess(readJson(file));
315
+ const current = readTask(repo, task.id);
316
+ // A shadow bot's turn that was cut off leaves the session where the agent left it.
317
+ const shadowed = current.shadowing?.state ?? null; delete current.shadowing;
318
+ Object.assign(current, shadowed ? { state: shadowed, activeRole: null, activeLog: null } : { state: 'stopped', activeRole: null, activeLog: null, error: { message: 'ELU Code stopped before this response finished. Retry to continue.' } });
319
+ saveTask(repo, current);
320
+ } finally { release(); }
321
+ }
322
+ let rows = []; try { rows = pendingRequests(repo.root); } catch { continue; }
323
+ for (const row of rows.filter(row => ['starting', 'running'].includes(row.state))) {
324
+ if (row.origin && row.taskId) { try { writeOutbox(repo.root, row, { taskId: row.taskId, outcome: 'failed', error: 'The run was interrupted.' }); } catch (error) { this.error = error.message; } }
325
+ updateRequest(repo.root, row.id, row.taskId ? { state: 'done', finishedAt: new Date().toISOString() } : { state: 'queued', reason: 'Waiting to start.' });
326
+ }
327
+ }
328
+ }
329
+ write(stopped = false) {
330
+ const runs = [...this.runs.values()].map(({ root, project, taskId, requestId, startedAt, attempts, stalled, origin }) => ({ root, project, taskId, requestId, startedAt, attempts, stalled: Boolean(stalled), origin }));
331
+ const value = { pid: process.pid, startedAt: this.startedAt, stopped, error: this.error, queued: this.queued, runs };
332
+ const text = JSON.stringify(value);
333
+ if (!stopped && text === this.written && Date.now() - this.writtenAt < 2000) return;
334
+ mkdir(folder()); atomic(statusFile(), { ...value, heartbeatAt: new Date().toISOString() });
335
+ this.written = text; this.writtenAt = Date.now();
336
+ }
337
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,147 @@
1
+ import { parseYaml } from './yaml.mjs';
2
+ import { exactKeys, isObject, refuse, relativePath } from './util.mjs';
3
+ import { MODEL_NAME, VENDORS } from './vendors.mjs';
4
+
5
+ // What no run does, whatever a project's config says. One thing opens, and not from a config
6
+ // file: a bot its owner switched on to merge pull requests may run gh pr merge in its own
7
+ // working turn. Nothing else in the list opens for anyone.
8
+ export const NEVER = Object.freeze(['push', 'force-push', 'tag', 'merge', 'deploy', 'publish']);
9
+ // roles.lead is the model that does the work. Reviews are done by the session's bots, so a
10
+ // new config names no reviewer.
11
+ export function defaultConfig() {
12
+ return {
13
+ version: 1,
14
+ roles: {
15
+ lead: { vendor: 'claude', access: 'edit', network: 'off', commands: [], writablePaths: ['.'], timeoutSeconds: 900, maxCostUsd: null },
16
+ },
17
+ never: [...NEVER],
18
+ checks: [{ name: 'tests', command: ['npm', 'test'] }, { name: 'build', command: ['npm', 'run', 'build'] }],
19
+ limits: { changedLines: 400, reviewRounds: 2 },
20
+ paths: ['.'],
21
+ };
22
+ }
23
+ function strings(value, label) {
24
+ if (!Array.isArray(value) || value.some((x) => typeof x !== 'string') || new Set(value).size !== value.length) refuse(`${label} must be a list of distinct strings.`);
25
+ }
26
+ function integer(value, min, max, label) {
27
+ if (!Number.isInteger(value) || value < min || value > max) refuse(`${label} must be an integer from ${min} to ${max}.`);
28
+ }
29
+ export function validateConfig(value) {
30
+ exactKeys(value, ['version', 'roles', 'never', 'checks', 'limits', 'paths', 'sandboxHelper', 'mcp', 'mcpOff'], 'Config');
31
+ // mcpOff: Claude Code MCP servers turned off for this project's lead. mcp is the old opt-in
32
+ // list, still read so older settings load; every server is now on unless turned off.
33
+ for (const key of ['mcp', 'mcpOff']) if (value[key] !== undefined) { strings(value[key], key); if (value[key].length > 20 || value[key].some(name => !/^[A-Za-z0-9_.-]{1,64}$/.test(name))) refuse(`${key} lists up to 20 MCP server names.`); }
34
+ // The key is still read so an older file that holds null loads. A path is refused: this file
35
+ // comes with the repository, and the helper is started outside the sandbox.
36
+ if (value.sandboxHelper !== undefined && value.sandboxHelper !== null) refuse('A project file cannot name the sandbox helper. Remove sandboxHelper from .elu-code/config.yaml and set ELU_CODE_SANDBOX_HELPER instead.');
37
+ if (value.version !== 1) refuse('Config version must be 1.');
38
+ // An entry other than lead (reviewer, security and so on) is from before bots did the
39
+ // reviews. It is still checked so an older config loads as it always did; nothing runs it.
40
+ if (!isObject(value.roles) || Object.keys(value.roles).length > 8 || Object.keys(value.roles).some(name => !/^[a-z][a-z0-9-]{0,29}$/.test(name) || ['constructor', 'prototype'].includes(name))) refuse('The config holds up to eight model entries with plain names.');
41
+ for (const [name, role] of Object.entries(value.roles)) {
42
+ // The owner reads "the model" for the entry that does the work; the others go by their name.
43
+ const who = name === 'lead' ? 'The model' : name, own = name === 'lead' ? "The model's" : name;
44
+ exactKeys(role, ['vendor', 'access', 'network', 'commands', 'writablePaths', 'timeoutSeconds', 'maxCostUsd', 'model'], `${own} policy`);
45
+ if (![...VENDORS, ...(name === 'reviewer' ? ['auto', 'none'] : [])].includes(role.vendor)) refuse(`${who} has an unsupported vendor.`);
46
+ // model: optional, for vendors whose CLI takes one (CAPS.model); the policy check says whether this vendor does.
47
+ if (role.model !== undefined && (typeof role.model !== 'string' || !MODEL_NAME.test(role.model))) refuse(`${name === 'lead' ? 'The model' : `The ${name} model`} must be a plain model name.`);
48
+ if (!['read', 'edit'].includes(role.access) || !['off', 'on'].includes(role.network)) refuse(`${who} has invalid access or network rules.`);
49
+ if (name !== 'lead' && role.access !== 'read') refuse(`${name} must be read-only. Only the model edits.`);
50
+ strings(role.commands, `${own} commands`); strings(role.writablePaths, `${own} writablePaths`);
51
+ for (const file of role.writablePaths) relativePath(file);
52
+ if (role.access === 'read' && role.writablePaths.length) refuse(`${who} is read-only, so it cannot have writable paths.`);
53
+ integer(role.timeoutSeconds, 1, 3600, `${own} timeoutSeconds`);
54
+ if (role.maxCostUsd !== null && (typeof role.maxCostUsd !== 'number' || !Number.isFinite(role.maxCostUsd) || role.maxCostUsd <= 0 || role.maxCostUsd > 100)) refuse(`${own} maxCostUsd must be null or from 0 to 100, excluding 0.`);
55
+ }
56
+ if (!value.roles.lead) refuse('The config names no model to do the work.');
57
+ strings(value.never, 'never');
58
+ if (value.never.length !== NEVER.length || NEVER.some((x) => !value.never.includes(x))) refuse('The fixed never list cannot be weakened or extended.');
59
+ strings(value.paths, 'paths'); if (!value.paths.length) refuse('Declare at least one allowed path.');
60
+ for (const p of value.paths) relativePath(p);
61
+ exactKeys(value.limits, ['changedLines', 'reviewRounds'], 'Limits');
62
+ integer(value.limits.changedLines, 1, 10000, 'changedLines'); integer(value.limits.reviewRounds, 1, 2, 'reviewRounds');
63
+ if (!Array.isArray(value.checks) || value.checks.length > 20) refuse('Declare from 0 to 20 checks.');
64
+ const names = new Set();
65
+ for (const check of value.checks) {
66
+ exactKeys(check, ['name', 'command'], 'Check');
67
+ if (typeof check.name !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(check.name) || names.has(check.name)) refuse('Check names must be distinct plain words.');
68
+ names.add(check.name);
69
+ if (!Array.isArray(check.command) || !check.command.length || check.command.some((x) => typeof x !== 'string' || !x || /[\x00-\x1f]/.test(x))) refuse(`Check ${check.name} needs an argument list.`);
70
+ if (check.command[0].startsWith('-') || check.command[0].includes('/')) refuse('Check programs must be found on PATH.');
71
+ }
72
+ return value;
73
+ }
74
+ export function parseConfig(text) { return validateConfig(parseYaml(text)); }
75
+
76
+ const ASK = { type: 'object', additionalProperties: false, properties: { to: { type: 'string', minLength: 1, maxLength: 64 }, project: { type: 'string', maxLength: 64 }, question: { type: 'string', minLength: 1, maxLength: 2000 } }, required: ['to', 'project', 'question'] };
77
+ // asks is offered only when teammates exist. It is listed as required so strict
78
+ // structured-output modes accept the schema; an empty list means no drafts.
79
+ export function resultSchema({ asks = false } = {}) {
80
+ const schema = {
81
+ type: 'object', additionalProperties: false,
82
+ properties: {
83
+ outcome: { type: 'string', enum: ['done', 'needs_input', 'blocked'] }, summary: { type: 'string', minLength: 1, maxLength: 4000 },
84
+ question: { type: ['string', 'null'], maxLength: 2000 }, choices: { type: 'array', items: { type: 'string' }, maxItems: 8 },
85
+ comments: { type: 'array', maxItems: 30, items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, severity: { type: 'string', enum: ['must_fix', 'suggestion'] }, body: { type: 'string' } }, required: ['id', 'severity', 'body'] } },
86
+ responses: { type: 'array', maxItems: 30, items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, action: { type: 'string', enum: ['fixed', 'disputed'] }, reason: { type: 'string' } }, required: ['id', 'action', 'reason'] } },
87
+ }, required: ['outcome', 'summary', 'question', 'choices', 'comments', 'responses'],
88
+ };
89
+ if (asks) { schema.properties.asks = { type: 'array', maxItems: 3, items: ASK }; schema.required.push('asks'); }
90
+ return schema;
91
+ }
92
+ // schedule: offered to a bot that has a schedule when the owner writes to it, so the owner
93
+ // can tell it in plain words to stop or start running by itself in this session.
94
+ const SCHEDULE_WORDS = Object.freeze(['keep', 'off', 'on']);
95
+ // name: offered while the session has no name, so the first reply names it.
96
+ export function conversationSchema({ intents = ['answer', 'edit'], asks = false, schedule = false, name = false } = {}) {
97
+ const schema = resultSchema({ asks });
98
+ if (name) { schema.properties.name = { type: 'string' }; schema.required.push('name'); }
99
+ schema.properties.intent = { type: 'string', enum: intents };
100
+ schema.required.push('intent');
101
+ // The project may hold no code: the lead names the repository it would change.
102
+ if (intents.includes('edit')) { schema.properties.repository = { type: 'string', maxLength: 1000 }; schema.required.push('repository'); }
103
+ if (schedule) { schema.properties.schedule = { type: 'string', enum: [...SCHEDULE_WORDS] }; schema.required.push('schedule'); }
104
+ return schema;
105
+ }
106
+ // Peer runs offer only answer, but an edit intent is still parsed so the runner can turn it into an answer.
107
+ // who: what replied, as the owner reads it; a bot is @name.
108
+ export function validateConversation(value, { asks = false, who = 'The model', schedule = false } = {}) {
109
+ exactKeys(value, ['outcome', 'summary', 'question', 'choices', 'comments', 'responses', 'intent', 'repository', 'name', ...(asks ? ['asks'] : []), ...(schedule ? ['schedule'] : [])], 'Conversation result');
110
+ if (!['answer', 'edit'].includes(value.intent)) refuse(`${who} returned an invalid conversation intent.`);
111
+ if (value.repository !== undefined && (typeof value.repository !== 'string' || value.repository.length > 1000 || /[\x00-\x1f\x7f]/.test(value.repository))) refuse(`${who} returned an invalid repository.`);
112
+ // A reply that leaves schedule out, or says anything but off or on, changes nothing.
113
+ // A name is passed on as it came and never fails a reply: whoever saves it checks it.
114
+ const { intent, repository, schedule: word, name, ...result } = value; return { ...validateResult(result, { asks }), intent, ...(repository?.trim() ? { repository: repository.trim() } : {}), ...(schedule && ['off', 'on'].includes(word) ? { schedule: word } : {}), ...(typeof name === 'string' ? { name } : {}) };
115
+ }
116
+ function validateAsks(value) {
117
+ if (!Array.isArray(value) || value.length > 3) refuse('Draft at most three asks.');
118
+ for (const row of value) {
119
+ exactKeys(row, ['to', 'project', 'question'], 'Draft ask');
120
+ if (typeof row.to !== 'string' || !row.to.trim() || row.to.length > 64 || /[\x00-\x1f\x7f]/.test(row.to)) refuse('A draft ask needs a teammate name.');
121
+ if (typeof row.project !== 'string' || row.project.length > 64 || /[\x00-\x1f\x7f]/.test(row.project)) refuse('A draft ask has an invalid project.');
122
+ if (typeof row.question !== 'string' || !row.question.trim() || row.question.length > 2000) refuse('A draft ask needs a question of 1 to 2000 characters.');
123
+ }
124
+ }
125
+ export function validateResult(value, { asks = false } = {}) {
126
+ exactKeys(value, ['outcome', 'summary', 'question', 'choices', 'comments', 'responses', ...(asks ? ['asks'] : [])], 'Run result');
127
+ if (value.asks !== undefined) validateAsks(value.asks);
128
+ if (!['done', 'needs_input', 'blocked'].includes(value.outcome) || typeof value.summary !== 'string' || !value.summary.trim() || value.summary.length > 4000) refuse('The run returned an invalid outcome or summary.');
129
+ if (!(value.question === null || (typeof value.question === 'string' && value.question.length <= 2000))) refuse('The run returned an invalid question.');
130
+ strings(value.choices, 'Result choices'); if (value.choices.length > 8) refuse('Too many choices.');
131
+ // A needs_input result may leave question empty. After a blocked step a model names what it
132
+ // needs in the summary, and whatever shows a result falls back to the summary.
133
+ for (const field of ['comments', 'responses']) {
134
+ if (!Array.isArray(value[field]) || value[field].length > 30) refuse(`Invalid ${field}.`);
135
+ const ids = new Set();
136
+ for (const row of value[field]) {
137
+ if (!isObject(row)) refuse(`Invalid ${field} entry.`);
138
+ exactKeys(row, field === 'comments' ? ['id', 'severity', 'body'] : ['id', 'action', 'reason'], field);
139
+ if (typeof row.id !== 'string' || !/^[a-zA-Z0-9_-]{1,40}$/.test(row.id) || ids.has(row.id)) refuse('Review IDs must be distinct plain words.');
140
+ ids.add(row.id);
141
+ const text = field === 'comments' ? row.body : row.reason;
142
+ if (typeof text !== 'string' || !text.trim() || text.length > 4000) refuse('Review text is missing or too long.');
143
+ if (field === 'comments' ? !['must_fix', 'suggestion'].includes(row.severity) : !['fixed', 'disputed'].includes(row.action)) refuse('Invalid review decision.');
144
+ }
145
+ }
146
+ return value;
147
+ }