@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.
- package/LICENSE +21 -0
- package/LICENSES/Apache-2.0.txt +202 -0
- package/README.md +327 -2
- package/SECURITY.md +333 -0
- package/THIRD_PARTY_NOTICES.md +62 -0
- package/bin/elu-code.mjs +209 -0
- package/bin/elu.cjs +10 -0
- package/bin/start.cjs +6 -0
- package/docs/peer-network.md +257 -0
- package/docs/permissions.md +285 -0
- package/docs/vendors/antigravity.md +131 -0
- package/docs/vendors/claude.md +124 -0
- package/docs/vendors/codex.md +64 -0
- package/docs/vendors/grok.md +151 -0
- package/lib/acp-client.mjs +100 -0
- package/lib/adapters/agy.mjs +283 -0
- package/lib/adapters/claude.mjs +311 -0
- package/lib/adapters/codex.mjs +137 -0
- package/lib/adapters/grok.mjs +384 -0
- package/lib/adapters/index.mjs +13 -0
- package/lib/agents.mjs +77 -0
- package/lib/agy-hook.mjs +53 -0
- package/lib/approval-server.mjs +85 -0
- package/lib/approvals.mjs +128 -0
- package/lib/awake.mjs +45 -0
- package/lib/board.mjs +261 -0
- package/lib/bot-models.mjs +71 -0
- package/lib/bot-schedules.mjs +291 -0
- package/lib/bots.mjs +361 -0
- package/lib/changes.mjs +168 -0
- package/lib/checks.mjs +102 -0
- package/lib/conductor.mjs +337 -0
- package/lib/config.mjs +147 -0
- package/lib/conformance.mjs +109 -0
- package/lib/default-bots.mjs +150 -0
- package/lib/detect.mjs +108 -0
- package/lib/diff.mjs +217 -0
- package/lib/editor.mjs +165 -0
- package/lib/format.mjs +51 -0
- package/lib/git.mjs +173 -0
- package/lib/init.mjs +129 -0
- package/lib/install.mjs +130 -0
- package/lib/kanban.mjs +180 -0
- package/lib/mcp.mjs +79 -0
- package/lib/memory.mjs +35 -0
- package/lib/models.mjs +144 -0
- package/lib/modes.mjs +95 -0
- package/lib/never-list.mjs +110 -0
- package/lib/never-shims.mjs +311 -0
- package/lib/paste.mjs +56 -0
- package/lib/peer/a2a-http.mjs +166 -0
- package/lib/peer/a2a.mjs +357 -0
- package/lib/peer/asks.mjs +329 -0
- package/lib/peer/canonical.mjs +72 -0
- package/lib/peer/cli.mjs +154 -0
- package/lib/peer/daemon.mjs +525 -0
- package/lib/peer/identity.mjs +81 -0
- package/lib/peer/index.mjs +291 -0
- package/lib/peer/intake.mjs +207 -0
- package/lib/peer/invite.mjs +76 -0
- package/lib/peer/paths.mjs +7 -0
- package/lib/peer/peerdata.mjs +21 -0
- package/lib/peer/pictures.mjs +295 -0
- package/lib/peer/plans.mjs +182 -0
- package/lib/peer/relay.mjs +31 -0
- package/lib/peer/replies.mjs +128 -0
- package/lib/peer/roster.mjs +256 -0
- package/lib/peer/rpc.mjs +88 -0
- package/lib/peer/secrets.mjs +88 -0
- package/lib/peer/settings.mjs +45 -0
- package/lib/peer/shares.mjs +120 -0
- package/lib/peer/status.mjs +84 -0
- package/lib/peer/taskstore.mjs +108 -0
- package/lib/peer/transport.mjs +120 -0
- package/lib/pictures.mjs +104 -0
- package/lib/plan.mjs +238 -0
- package/lib/policy.mjs +98 -0
- package/lib/process.mjs +92 -0
- package/lib/project-settings.mjs +55 -0
- package/lib/project-trust.mjs +230 -0
- package/lib/projects.mjs +668 -0
- package/lib/publish.mjs +105 -0
- package/lib/runner.mjs +874 -0
- package/lib/sandbox.mjs +102 -0
- package/lib/schedules.mjs +270 -0
- package/lib/selection.mjs +202 -0
- package/lib/shell.mjs +101 -0
- package/lib/state.mjs +247 -0
- package/lib/terminal-bots.mjs +239 -0
- package/lib/terminal-commands.mjs +165 -0
- package/lib/terminal-plan.mjs +37 -0
- package/lib/terminal-visuals.mjs +268 -0
- package/lib/terminal.mjs +3159 -0
- package/lib/unit-file.mjs +17 -0
- package/lib/usage.mjs +249 -0
- package/lib/util.mjs +86 -0
- package/lib/vendor-check.mjs +96 -0
- package/lib/vendors.mjs +191 -0
- package/lib/workspace.mjs +168 -0
- package/lib/yaml.mjs +624 -0
- package/npm-shrinkwrap.json +827 -0
- package/package.json +70 -4
package/lib/plan.mjs
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { atomic, dataBlock, isObject, noSymlinks, readJson, refuse } from './util.mjs';
|
|
5
|
+
import { taskDir, titleFromTask, waitLock } from './state.mjs';
|
|
6
|
+
|
|
7
|
+
// A session's Task: what its owner agrees to before the work starts. A request that
|
|
8
|
+
// changes files, or needs planning, comes back from the model as a proposed Task: a
|
|
9
|
+
// title, a description, the goal, the objectives that say when it is done, a priority and
|
|
10
|
+
// a severity. The owner approves or rejects it, and ELU Code starts an editing turn only
|
|
11
|
+
// for an approved Task. After approval the model splits the work into subtasks and
|
|
12
|
+
// changes them as it likes; the goal and the objectives are the owner's, and change only
|
|
13
|
+
// through a new proposal.
|
|
14
|
+
// Tasks are kept in plan.json beside the session record, written whole under a lock of
|
|
15
|
+
// their own. A turn holds the session record for its whole length and writes it back at
|
|
16
|
+
// its end, so an approval kept there would be overwritten; here it never is.
|
|
17
|
+
|
|
18
|
+
export const PRIORITIES = Object.freeze(['urgent', 'high', 'medium', 'low']);
|
|
19
|
+
export const SEVERITIES = Object.freeze(['critical', 'high', 'medium', 'low']);
|
|
20
|
+
export const TASK_LIMITS = Object.freeze({ title: 80, description: 2000, goal: 500, objective: 300, objectives: 8, subtask: 200, subtasks: 30, kept: 20 });
|
|
21
|
+
const STATES = ['proposed', 'approved', 'rejected', 'done'], STEPS = ['todo', 'doing', 'done'];
|
|
22
|
+
// Control characters, and the marks that reorder or hide text on a screen.
|
|
23
|
+
const CONTROL = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]/;
|
|
24
|
+
// Where a session's Tasks are kept.
|
|
25
|
+
export const planFile = (repo, id) => path.join(taskDir(repo, id), 'plan.json');
|
|
26
|
+
const file = planFile;
|
|
27
|
+
const stored = row => isObject(row) && typeof row.id === 'string' && typeof row.title === 'string' && STATES.includes(row.state) && Array.isArray(row.objectives) && Array.isArray(row.subtasks) && Array.isArray(row.met);
|
|
28
|
+
|
|
29
|
+
// Every Task of a session, oldest first. An unreadable file reads as none, so it never
|
|
30
|
+
// stops a turn; with none, the next request is planned again.
|
|
31
|
+
export function sessionTasks(repo, id) {
|
|
32
|
+
try {
|
|
33
|
+
const target = file(repo, id); noSymlinks(target);
|
|
34
|
+
if (!fs.existsSync(target)) return [];
|
|
35
|
+
const value = readJson(target);
|
|
36
|
+
return Array.isArray(value?.tasks) ? value.tasks.filter(stored) : [];
|
|
37
|
+
} catch { return []; }
|
|
38
|
+
}
|
|
39
|
+
// The Task a session shows: its newest one.
|
|
40
|
+
export const currentTask = (repo, id) => sessionTasks(repo, id).at(-1) ?? null;
|
|
41
|
+
// The Task whose work is going on: the newest approved one.
|
|
42
|
+
export const openTask = rows => rows.findLast(row => row.state === 'approved') ?? null;
|
|
43
|
+
// The Task that waits for the owner: only ever the newest.
|
|
44
|
+
export const waitingTask = rows => rows.at(-1)?.state === 'proposed' ? rows.at(-1) : null;
|
|
45
|
+
function change(repo, id, edit) {
|
|
46
|
+
const release = waitLock(repo, `plan-${id}`, 200);
|
|
47
|
+
try { const rows = sessionTasks(repo, id), result = edit(rows); atomic(file(repo, id), { version: 1, tasks: rows.slice(-TASK_LIMITS.kept) }); return result; }
|
|
48
|
+
finally { release(); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A Task is the model's text, so it is checked like any other: plain text of a stated
|
|
52
|
+
// length, a priority and a severity from the lists. Markup is shown as typed, never run.
|
|
53
|
+
function words(value, max, label, { lines = false } = {}) {
|
|
54
|
+
if (typeof value !== 'string') refuse(`The Task needs ${label}.`);
|
|
55
|
+
const text = lines ? value.replace(/\r\n?/g, '\n').replace(/\t/g, ' ').split('\n').map(row => row.trimEnd()).join('\n').replace(/\n{3,}/g, '\n\n').trim() : value.replace(/\s+/g, ' ').trim();
|
|
56
|
+
if (!text) refuse(`The Task needs ${label}.`);
|
|
57
|
+
if (CONTROL.test(text)) refuse(`The Task's text holds control characters. Write ${label} as plain text.`);
|
|
58
|
+
if (text.length > max) refuse(`Write ${label} in at most ${max} characters.`);
|
|
59
|
+
return text;
|
|
60
|
+
}
|
|
61
|
+
export function checkProposal(raw) {
|
|
62
|
+
if (!isObject(raw)) refuse('The Task is an object with a title, a description, a goal, objectives, a priority and a severity.');
|
|
63
|
+
if (!Array.isArray(raw.objectives) || raw.objectives.length < 1 || raw.objectives.length > TASK_LIMITS.objectives) refuse(`A Task has 1 to ${TASK_LIMITS.objectives} objectives.`);
|
|
64
|
+
if (!PRIORITIES.includes(raw.priority)) refuse(`A Task's priority is one of: ${PRIORITIES.join(', ')}.`);
|
|
65
|
+
if (!SEVERITIES.includes(raw.severity)) refuse(`A Task's severity is one of: ${SEVERITIES.join(', ')}.`);
|
|
66
|
+
return {
|
|
67
|
+
title: words(raw.title, TASK_LIMITS.title, 'a title of one line'), description: words(raw.description, TASK_LIMITS.description, 'a description', { lines: true }), goal: words(raw.goal, TASK_LIMITS.goal, 'a goal'),
|
|
68
|
+
objectives: raw.objectives.map(row => words(row, TASK_LIMITS.objective, 'each objective as one line')), priority: raw.priority, severity: raw.severity,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// Subtasks are the model's own business: a row that does not read is left out, never refused.
|
|
72
|
+
function readSubtasks(value) {
|
|
73
|
+
if (!Array.isArray(value)) return [];
|
|
74
|
+
return value.slice(0, TASK_LIMITS.subtasks).flatMap(row => { const text = typeof row?.text === 'string' ? row.text.replace(/\s+/g, ' ').trim().slice(0, TASK_LIMITS.subtask) : ''; return text && !CONTROL.test(text) ? [{ text, state: STEPS.includes(row.state) ? row.state : 'todo' }] : []; })
|
|
75
|
+
.map((row, index) => ({ id: `s${index + 1}`, ...row }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The fields a reply carries, added to the schema every vendor is already given. Only
|
|
79
|
+
// constructs the result schema already uses: a list of objects, strings, enums.
|
|
80
|
+
// propose: the reply may hold a Task (a list of one, or empty). progress: it reports
|
|
81
|
+
// subtasks and the objectives that are met.
|
|
82
|
+
const TASK_SCHEMA = () => ({ type: 'object', additionalProperties: false, properties: {
|
|
83
|
+
title: { type: 'string', maxLength: TASK_LIMITS.title }, description: { type: 'string', maxLength: TASK_LIMITS.description }, goal: { type: 'string', maxLength: TASK_LIMITS.goal },
|
|
84
|
+
objectives: { type: 'array', maxItems: TASK_LIMITS.objectives, items: { type: 'string', maxLength: TASK_LIMITS.objective } }, priority: { type: 'string', enum: [...PRIORITIES] }, severity: { type: 'string', enum: [...SEVERITIES] },
|
|
85
|
+
}, required: ['title', 'description', 'goal', 'objectives', 'priority', 'severity'] });
|
|
86
|
+
export function planSchema(schema, { propose = false, progress = false } = {}) {
|
|
87
|
+
if (propose) { schema.properties.task = { type: 'array', maxItems: 1, items: TASK_SCHEMA() }; schema.required.push('task'); }
|
|
88
|
+
if (progress) {
|
|
89
|
+
schema.properties.subtasks = { type: 'array', maxItems: TASK_LIMITS.subtasks, items: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', maxLength: TASK_LIMITS.subtask }, state: { type: 'string', enum: [...STEPS] } }, required: ['text', 'state'] } };
|
|
90
|
+
schema.properties.objectivesMet = { type: 'array', maxItems: TASK_LIMITS.objectives, items: { type: 'integer' } };
|
|
91
|
+
schema.required.push('subtasks', 'objectivesMet');
|
|
92
|
+
}
|
|
93
|
+
return schema;
|
|
94
|
+
}
|
|
95
|
+
// Takes the Task fields off a reply before the result's own check, which accepts no other
|
|
96
|
+
// key. strict: a Task that does not read is refused, so the model is asked for it once
|
|
97
|
+
// more; the second time it is left out and the reply is kept as a plain answer.
|
|
98
|
+
export function readPlanReply(value, { propose = false, progress = false, strict = true } = {}) {
|
|
99
|
+
if (!isObject(value)) return { rest: value, reply: null };
|
|
100
|
+
const { task, subtasks, objectivesMet, ...rest } = value, reply = {};
|
|
101
|
+
const given = Array.isArray(task) ? task : task == null ? [] : [task];
|
|
102
|
+
if (propose && given.length) {
|
|
103
|
+
try { if (given.length > 1) refuse('Return one Task at most.'); reply.proposal = checkProposal(given[0]); }
|
|
104
|
+
catch (error) { if (strict) throw error; reply.unread = true; }
|
|
105
|
+
}
|
|
106
|
+
if (progress) {
|
|
107
|
+
const steps = readSubtasks(subtasks), met = Array.isArray(objectivesMet) ? [...new Set(objectivesMet.filter(row => Number.isInteger(row) && row >= 1 && row <= TASK_LIMITS.objectives))].sort((a, b) => a - b) : [];
|
|
108
|
+
if (steps.length) reply.subtasks = steps;
|
|
109
|
+
if (met.length) reply.met = met;
|
|
110
|
+
}
|
|
111
|
+
return { rest, reply: Object.keys(reply).length ? reply : null };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const newId = () => `t-${crypto.randomBytes(4).toString('hex')}`;
|
|
115
|
+
// Records what the model proposed. A Task that already waits is replaced by this one as
|
|
116
|
+
// its next revision. startedBy says who started the work: the owner, who approves it first;
|
|
117
|
+
// a schedule, which has nobody to approve; or a teammate's handoff the owner chose to work
|
|
118
|
+
// on. The last two are recorded as approved at once, and a Task that waits for the owner
|
|
119
|
+
// stays the one on the card.
|
|
120
|
+
// edit: what approval starts ({ intent, repository, bot, request }); it stays on this machine.
|
|
121
|
+
export function proposeTask(repo, id, fields, { by = 'lead', startedBy = 'owner', edit = null } = {}) {
|
|
122
|
+
const now = new Date().toISOString();
|
|
123
|
+
return nameSession(repo, id, change(repo, id, rows => {
|
|
124
|
+
const waiting = waitingTask(rows), blank = { id: newId(), session: String(id), revision: 1, ...fields, state: 'proposed', startedBy, by, proposedAt: now, approvedAt: null, rejectedAt: null, doneAt: null, subtasks: [], met: [], edit };
|
|
125
|
+
if (startedBy !== 'owner') {
|
|
126
|
+
const task = { ...blank, state: 'approved', approvedAt: now, edit: null };
|
|
127
|
+
rows.splice(waiting ? rows.length - 1 : rows.length, 0, task); return task;
|
|
128
|
+
}
|
|
129
|
+
if (waiting) return Object.assign(waiting, fields, { revision: waiting.revision + 1, by, proposedAt: now, edit });
|
|
130
|
+
rows.push(blank); return blank;
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
// A session is named after its approved Task, unless the owner named it (lib/state.mjs).
|
|
134
|
+
// The name is cosmetic: a session that cannot take it keeps the name it shows.
|
|
135
|
+
function nameSession(repo, id, task) {
|
|
136
|
+
if (task.state === 'approved') { try { titleFromTask(repo, id, task.title); } catch { /* The session keeps the name it shows. */ } }
|
|
137
|
+
return task;
|
|
138
|
+
}
|
|
139
|
+
export function approveTask(repo, id) {
|
|
140
|
+
return nameSession(repo, id, change(repo, id, rows => Object.assign(waitingTask(rows) ?? refuse('No Task waits for your approval in this session.'), { state: 'approved', approvedAt: new Date().toISOString() })));
|
|
141
|
+
}
|
|
142
|
+
export function rejectTask(repo, id) {
|
|
143
|
+
return change(repo, id, rows => Object.assign(waitingTask(rows) ?? refuse('No Task waits for your approval in this session.'), { state: 'rejected', rejectedAt: new Date().toISOString() }));
|
|
144
|
+
}
|
|
145
|
+
// What may leave this machine or be shown to someone else: the Task and its progress,
|
|
146
|
+
// never edit, which names a repository path and the owner's own message.
|
|
147
|
+
export const sharedTask = task => task ? { id: task.id, session: task.session, revision: task.revision, title: task.title, description: task.description, goal: task.goal, objectives: [...task.objectives], priority: task.priority, severity: task.severity,
|
|
148
|
+
state: task.state, startedBy: task.startedBy, by: task.by, proposedAt: task.proposedAt, approvedAt: task.approvedAt, rejectedAt: task.rejectedAt, doneAt: task.doneAt, subtasks: task.subtasks.map(({ id, text, state }) => ({ id, text, state })), met: [...task.met] } : null;
|
|
149
|
+
// The approved Task whose work has not started yet, or null.
|
|
150
|
+
export function taskToStart(repo, id) {
|
|
151
|
+
const task = openTask(sessionTasks(repo, id));
|
|
152
|
+
return task?.edit && task.edit.started !== true ? task : null;
|
|
153
|
+
}
|
|
154
|
+
export function markStarted(repo, id) {
|
|
155
|
+
return change(repo, id, rows => { const task = openTask(rows); if (task?.edit) task.edit.started = true; return task; });
|
|
156
|
+
}
|
|
157
|
+
// What the model reports while it works. The goal and the objectives are not among the
|
|
158
|
+
// fields a report can carry.
|
|
159
|
+
export function reportProgress(repo, id, { subtasks, met } = {}) {
|
|
160
|
+
if (!subtasks && !met) return null;
|
|
161
|
+
return change(repo, id, rows => {
|
|
162
|
+
const task = openTask(rows); if (!task) return null;
|
|
163
|
+
if (subtasks) task.subtasks = subtasks;
|
|
164
|
+
if (met) task.met = met.filter(row => row <= task.objectives.length);
|
|
165
|
+
return task;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
// A finished Task opens again when the owner sends more work inside its goal.
|
|
169
|
+
export function reopenTask(repo, id) {
|
|
170
|
+
return change(repo, id, rows => { const task = rows.at(-1); if (task?.state === 'done') Object.assign(task, { state: 'approved', doneAt: null }); return task ?? null; });
|
|
171
|
+
}
|
|
172
|
+
// Done: the model reports every objective met, and the turn that did the work has ended.
|
|
173
|
+
export function finishTask(repo, id) {
|
|
174
|
+
const open = openTask(sessionTasks(repo, id));
|
|
175
|
+
if (!open || open.met.length < open.objectives.length) return null;
|
|
176
|
+
return change(repo, id, rows => { const task = openTask(rows); if (task && task.met.length >= task.objectives.length) Object.assign(task, { state: 'done', doneAt: new Date().toISOString() }); return task; });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The same at the end of a turn, where a Task that cannot be closed never fails the turn.
|
|
180
|
+
export function closeTask(repo, id) { try { return finishTask(repo, id); } catch { return null; } }
|
|
181
|
+
|
|
182
|
+
// What one turn is told about Tasks and may return. first: the turn that answers a
|
|
183
|
+
// message; work: an editing turn, which reports progress and proposes nothing.
|
|
184
|
+
// covered: the session has a Task the owner approved, so work inside its goal may go on.
|
|
185
|
+
// unattended: what started the turn when its Task waits for nobody (schedule or handoff), else false.
|
|
186
|
+
export function turnPlan(repo, id, { work = false, unattended = false } = {}) {
|
|
187
|
+
const rows = sessionTasks(repo, id), open = openTask(rows), last = rows.at(-1) ?? null;
|
|
188
|
+
if (work) return open ? { propose: false, progress: true, work: true, open } : null;
|
|
189
|
+
const agreed = open ?? (last?.state === 'done' ? last : null);
|
|
190
|
+
return { propose: true, progress: Boolean(agreed), waiting: waitingTask(rows), open: agreed, covered: Boolean(agreed), unattended };
|
|
191
|
+
}
|
|
192
|
+
const shown = task => ({ title: task.title, description: task.description, goal: task.goal, objectives: task.objectives.map((text, index) => `${index + 1}. ${text}`), priority: task.priority, severity: task.severity, state: task.state, revision: task.revision, subtasks: task.subtasks.map(({ text, state }) => ({ text, state })), objectivesMet: task.met });
|
|
193
|
+
const FIELDS = 'a title (one line), a description of the work, the goal, the objectives that say when it is done (one line each), a priority (urgent, high, medium or low) and a severity (critical, high, medium or low)';
|
|
194
|
+
const PROGRESS = 'Break the work into subtasks as you see fit and change them freely; they need no approval. Put the list as it stands in subtasks, each todo, doing or done, or leave subtasks empty to keep the list as it is. In objectivesMet give the numbers of the objectives that are met now.';
|
|
195
|
+
// The rule as every vendor's model is told it. What ELU Code enforces is the gate in
|
|
196
|
+
// ProjectController.ask: an editing turn starts only for an approved Task.
|
|
197
|
+
export function planPrompt(plan) {
|
|
198
|
+
if (!plan) return [];
|
|
199
|
+
if (plan.work) return [`The owner approved the Task below, and this turn does its work. Meet every objective. ${PROGRESS} The goal and the objectives are the owner's: if one has to change, stop, return outcome needs_input and say what should change.`, dataBlock('Approved Task', shown(plan.open))];
|
|
200
|
+
const parts = [`Plan first. When the owner asks for a change to code or files, or for work that needs planning, change nothing in this turn: read what you need, then put one Task in task with ${FIELDS}, and say in the summary what you found and how you would do it. ${plan.unattended === 'handoff' ? 'The owner chose to work on this handoff from a teammate: the Task is recorded as started by handoff and the work goes on.' : plan.unattended ? 'A schedule started this turn and nobody is there to approve: the Task is recorded as started by schedule and the work goes on.' : 'The owner approves or rejects the Task, and the work starts only after they approve.'} When you must ask the owner something before you can write the Task, ask it and leave task empty; the Task comes with a later reply. A question, an explanation or a greeting gets a plain answer and an empty task list.`];
|
|
201
|
+
if (plan.waiting) parts.push('The Task below waits for the owner\'s approval. When their message asks for changes to it, return the whole Task again in task, revised. When it only asks about it, answer and leave task empty: the Task stays as it is. Only the owner approves it, with a key; do not start its work.', dataBlock('Task waiting for approval', shown(plan.waiting)));
|
|
202
|
+
else if (plan.open) parts.push(`The owner approved the Task below. Work inside its goal needs no new Task: leave task empty. A new piece of work needs a Task of its own in task. To change the goal or an objective, return the whole revised Task in task; it needs the owner's approval again. ${PROGRESS}`, dataBlock('Approved Task', shown(plan.open)));
|
|
203
|
+
return parts;
|
|
204
|
+
}
|
|
205
|
+
export const NEEDS_TASK = `A request that changes files needs a Task first. Change nothing, set intent to edit, and put one Task in task with ${FIELDS}.`;
|
|
206
|
+
const NO_TASK = 'No Task was read from this reply, so nothing was changed. Send your request again.';
|
|
207
|
+
const STILL_WAITS = 'The Task still waits for your approval, so nothing was changed.';
|
|
208
|
+
const asAnswer = (response, line) => { response.intent = 'answer'; delete response.repository; if (line) response.summary = `${response.summary.slice(0, 3800)}\n\n${line}`; };
|
|
209
|
+
// What a first turn's reply does to the session's Tasks. Returns held when the reply
|
|
210
|
+
// must not start an edit: a Task now waits for the owner, or none was read. The reply is
|
|
211
|
+
// then kept as an answer, so the session rests where an answer leaves it.
|
|
212
|
+
// message: the owner's request. by: who answered, lead or @name. bot: the working bot that would make the edit.
|
|
213
|
+
export function settleReply(repo, task, response, { plan, message, by = 'lead', bot = null } = {}) {
|
|
214
|
+
const reply = response.plan ?? null; delete response.plan;
|
|
215
|
+
const edit = response.intent === 'edit' && response.outcome === 'done';
|
|
216
|
+
// A reply that asks the owner something first proposes nothing yet: its Task comes with the answer.
|
|
217
|
+
const proposal = response.outcome === 'done' ? reply?.proposal ?? null : null;
|
|
218
|
+
if (reply?.subtasks || reply?.met) reportProgress(repo, task.id, reply);
|
|
219
|
+
if (proposal) {
|
|
220
|
+
if (plan.unattended) { proposeTask(repo, task.id, proposal, { by, startedBy: plan.unattended === 'handoff' ? 'handoff' : 'schedule' }); return null; }
|
|
221
|
+
proposeTask(repo, task.id, proposal, { by, edit: { intent: response.intent, repository: response.repository ?? null, bot, request: message, started: false } });
|
|
222
|
+
if (edit) asAnswer(response);
|
|
223
|
+
return 'held';
|
|
224
|
+
}
|
|
225
|
+
if (!edit) return null;
|
|
226
|
+
if (plan.waiting) { asAnswer(response, STILL_WAITS); return 'held'; }
|
|
227
|
+
if (plan.covered) { reopenTask(repo, task.id); return null; }
|
|
228
|
+
asAnswer(response, NO_TASK); return 'held';
|
|
229
|
+
}
|
|
230
|
+
// What an editing turn's reply reports. Returns the reply without the Task fields.
|
|
231
|
+
export function settleWork(repo, id, result) {
|
|
232
|
+
const reply = result?.plan ?? null;
|
|
233
|
+
if (result) delete result.plan;
|
|
234
|
+
if (reply) { try { reportProgress(repo, id, reply); } catch { /* A report that cannot be kept never fails the work. */ } }
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
// The owner's own short yes, typed instead of the key.
|
|
238
|
+
export const saysYes = text => /^(y|yes|ok|okay|approve|approved|go|go ahead|do it|lgtm)[.! ]*$/i.test(String(text ?? '').trim());
|
package/lib/policy.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { refuse, relativePath, within } from './util.mjs';
|
|
5
|
+
import { CAPS, ENFORCEMENT, VENDOR_NAMES } from './vendors.mjs';
|
|
6
|
+
import { PROFILES } from './modes.mjs';
|
|
7
|
+
|
|
8
|
+
const INTERPRETERS = new Set(['node', 'nodejs', 'deno', 'bun', 'python', 'python2', 'python3', 'ruby', 'perl', 'php', 'lua', 'bash', 'sh', 'zsh', 'fish', 'dash', 'ksh', 'csh', 'tcsh', 'osascript', 'tsx', 'ts-node', 'awk', 'gawk']);
|
|
9
|
+
const RUNNERS = new Set(['npx', 'pnpx', 'bunx', 'env', 'xargs', 'eval', 'exec', 'source', '.', 'sudo', 'su', 'doas', 'nohup', 'timeout', 'nice', 'time', 'watch', 'command', 'builtin', 'make', 'docker', 'ssh', 'script', 'expect', 'parallel', 'find']);
|
|
10
|
+
const NETWORK = new Set(['curl', 'wget', 'http', 'https', 'nc', 'ncat', 'telnet', 'scp', 'sftp', 'ftp', 'rsync']);
|
|
11
|
+
const GIT_READS = new Set(['log', 'show', 'diff', 'status', 'blame', 'grep', 'rev-parse', 'ls-files', 'ls-tree', 'describe', 'shortlog', 'cat-file', 'rev-list', 'merge-base', 'name-rev']);
|
|
12
|
+
export function allowRuleProblem(rule) {
|
|
13
|
+
if (typeof rule !== 'string' || !rule.trim()) return 'an empty rule';
|
|
14
|
+
if (rule.length > 300 || /[\r\n]/.test(rule)) return 'a rule must fit on one line, within 300 characters';
|
|
15
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*(\([^\r\n]+\))?$/.test(rule)) return 'not a tool permission rule';
|
|
16
|
+
if (/^Bash(\(\s*(\*|:\*)?\s*\))?$/.test(rule)) return 'it allows every shell command';
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
export function allowRuleRisk(rule) {
|
|
20
|
+
const match = /^Bash\((.*)\)$/.exec(rule);
|
|
21
|
+
if (!match) return null;
|
|
22
|
+
const prefix = match[1].replace(/(?::\*|\s+\*)$/, '').trim();
|
|
23
|
+
const [first, ...rest] = prefix.split(/\s+/);
|
|
24
|
+
const program = path.basename(first);
|
|
25
|
+
if (RUNNERS.has(program) || INTERPRETERS.has(program)) return { level: 'any', text: `${program} can run arbitrary code.` };
|
|
26
|
+
if (['npm', 'pnpm', 'yarn'].includes(program)) {
|
|
27
|
+
if ((rest[0] === 'run' || rest[0] === 'run-script') && /^[a-zA-Z0-9:_-]+$/.test(rest[1] ?? '') && rest.length === 2) return null;
|
|
28
|
+
if (rest.length === 1 && ['test', 't'].includes(rest[0])) return null;
|
|
29
|
+
return { level: 'any', text: 'Name one package script; package runners and installs are refused.' };
|
|
30
|
+
}
|
|
31
|
+
if (NETWORK.has(program)) return { level: 'changes', text: 'It reaches another machine.' };
|
|
32
|
+
if (program === 'git' && GIT_READS.has(rest[0]) && rest.length === 1) return null;
|
|
33
|
+
return { level: 'broad', text: 'This command prefix has not been tested.' };
|
|
34
|
+
}
|
|
35
|
+
export function commandRule(command) {
|
|
36
|
+
if (typeof command !== 'string' || !/^[a-zA-Z0-9_-]+(?: [a-zA-Z0-9:_-]+)*$/.test(command)) refuse('Allowed commands must be plain program and argument prefixes, without shell syntax.');
|
|
37
|
+
const rule = `Bash(${command}:*)`;
|
|
38
|
+
const problem = allowRuleProblem(rule); const risk = allowRuleRisk(rule);
|
|
39
|
+
if (problem || risk) refuse(`Cannot allow ${command}: ${problem ?? risk.text}`, { risk: risk?.level });
|
|
40
|
+
return rule;
|
|
41
|
+
}
|
|
42
|
+
export function sensitive(file) {
|
|
43
|
+
return /(^|\/)(?:\.env(?:\..*)?|\.npmrc|\.netrc|\.pypirc|credentials(?:\.json)?|auth\.json|id_(?:rsa|ed25519)|[^/]+\.pem)$/.test(file);
|
|
44
|
+
}
|
|
45
|
+
// Dependency files stay protected in locked and ask; Approve for me and Full access may change them.
|
|
46
|
+
const DEPENDENCY = /(^|\/)(?:package\.json|package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|[^/]*\.lock)$/;
|
|
47
|
+
const dependenciesOpen = mode => ['auto', 'full'].includes(mode);
|
|
48
|
+
export function protectedPath(file, { mode = 'locked' } = {}) {
|
|
49
|
+
return /(^|\/)(?:\.elu-code|\.git|\.github|\.gitlab|\.circleci|\.claude|\.codex|\.agents|\.gemini|\.cursor|\.vscode|\.idea|\.zed|\.husky|\.githooks|node_modules)(\/|$)/.test(file)
|
|
50
|
+
|| /(^|\/)(?:\.yarnrc\.yml|\.pnpmfile\.cjs|pnpm-workspace\.yaml|bunfig\.toml|\.mocharc[^/]*|vitest\.workspace[^/]*|ava\.config[^/]*|karma\.conf[^/]*|\.nycrc[^/]*|pyproject\.toml|setup\.cfg|tox\.ini|pytest\.ini|conftest\.py)$/.test(file)
|
|
51
|
+
|| /(^|\/)(?:AGENTS(?:\.override)?\.md|CLAUDE(?:\.local)?\.md|\.mcp\.json|\.gitmodules|\.gitattributes|\.gitignore|\.npmrc|Jenkinsfile|Makefile|Dockerfile)$/.test(file)
|
|
52
|
+
|| (!dependenciesOpen(mode) && DEPENDENCY.test(file))
|
|
53
|
+
|| /(^|\/)(?:(?:vitest|vite|jest|webpack|rollup|babel|next|eslint|prettier|playwright|cypress|tsup)\.config\.[^/]+|tsconfig[^/]*\.json|\.babelrc[^/]*|\.eslintrc[^/]*)$/.test(file);
|
|
54
|
+
}
|
|
55
|
+
export function protectedFiles(files, { mode = 'locked' } = {}) {
|
|
56
|
+
return [...new Set(['.elu-code', '.git', '.github', '.gitlab', '.circleci', '.claude', '.codex', '.agents', '.gemini', '.cursor', '.vscode', '.idea', '.zed', '.husky', '.githooks', 'node_modules', 'AGENTS.md', 'AGENTS.override.md', 'CLAUDE.md', 'CLAUDE.local.md', '.mcp.json', '.gitignore', '.gitattributes', '.gitmodules', ...files.filter((x) => protectedPath(x, { mode }) || (!dependenciesOpen(mode) && /(^|\/)package\.json$/.test(x)))])];
|
|
57
|
+
}
|
|
58
|
+
// What enforces each rule for this role in this profile, from the vendor table. A reviewer
|
|
59
|
+
// set to auto or none reports like Codex, the vendor it may become.
|
|
60
|
+
export function policyReport(role, { mode = 'locked' } = {}) {
|
|
61
|
+
if (!PROFILES.includes(mode)) refuse('Choose ask, auto, or full.');
|
|
62
|
+
const table = ENFORCEMENT[['auto', 'none'].includes(role.vendor) ? 'codex' : role.vendor] ?? {};
|
|
63
|
+
const rules = (table[mode] ?? table.locked ?? []).map(({ rule, enforcement }) => ({ rule, enforcement: typeof enforcement === 'function' ? enforcement(role) : enforcement }));
|
|
64
|
+
return { vendor: role.vendor, access: role.access, rules };
|
|
65
|
+
}
|
|
66
|
+
// What a role may ask of its vendor, from CAPS. mode is the session profile: an edit role
|
|
67
|
+
// on a vendor without lockedEdit runs only in a mode that lifts the lock.
|
|
68
|
+
export function assertPolicy(role, { mode = 'locked' } = {}) {
|
|
69
|
+
const caps = CAPS[role.vendor];
|
|
70
|
+
if (!caps) refuse(`Choose a supported vendor: ${role.vendor}.`);
|
|
71
|
+
if (!PROFILES.includes(mode)) refuse('Choose ask, auto, or full.');
|
|
72
|
+
if (role.network !== 'off') refuse("Network comes from the session's permissions.");
|
|
73
|
+
for (const command of role.commands) commandRule(command);
|
|
74
|
+
const name = VENDOR_NAMES[role.vendor];
|
|
75
|
+
if (!caps.commands && role.commands.length) refuse(`${name} cannot enforce a per-run command allow-list. Remove the allowed commands, or choose Claude.`);
|
|
76
|
+
if (!caps.budget && role.maxCostUsd !== null) refuse(`${name} cannot enforce a dollar budget. Set maxCostUsd to null and use the time limit, or choose Claude.`);
|
|
77
|
+
if (mode === 'locked' && !caps.lockedEdit && role.access !== 'read') refuse(`${name} cannot edit in Read only. Choose Ask for approval, Approve for me, or Full access.`);
|
|
78
|
+
if (caps.model === 'none' && role.model !== undefined) refuse(`${name} does not take a model name.`);
|
|
79
|
+
if (caps.model === 'required' && !role.model) refuse(`${name} needs a model name.`);
|
|
80
|
+
if (role.access === 'read' && role.commands.length) refuse('A read-only run uses file tools or supplied source context, without shell commands.');
|
|
81
|
+
if (role.access === 'edit' && !role.writablePaths.length) refuse('Editing needs writable paths.');
|
|
82
|
+
if (role.vendor === 'claude' && role.commands.length && (role.writablePaths.length !== 1 || role.writablePaths[0] !== '.')) refuse('Claude shell access with narrower writable paths has not been tested. Use no shell commands or allow the worktree.');
|
|
83
|
+
for (const p of role.writablePaths) relativePath(p);
|
|
84
|
+
return role;
|
|
85
|
+
}
|
|
86
|
+
export function assertNoCodexProjectConfig(cwd) {
|
|
87
|
+
const home = os.homedir();
|
|
88
|
+
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
|
|
89
|
+
if (dir !== home && fs.existsSync(path.join(dir, '.codex', 'config.toml'))) refuse('Codex project configuration could add MCP servers. Move it out of this task before running.');
|
|
90
|
+
if (dir === path.dirname(dir)) break;
|
|
91
|
+
}
|
|
92
|
+
for (const file of ['/etc/codex/config.toml', '/etc/codex/managed_config.toml', '/etc/codex/requirements.toml', '/Library/Application Support/com.openai.codex/config.toml', '/Library/Application Support/com.openai.codex/managed_config.toml']) {
|
|
93
|
+
if (fs.existsSync(file)) refuse('Managed Codex configuration has not passed the no-MCP tests.');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function pathAllowed(file, paths) {
|
|
97
|
+
return paths.some((p) => p === '.' || within(path.resolve('/', p), path.resolve('/', file)));
|
|
98
|
+
}
|
package/lib/process.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import { atomic, noSymlinks, personalDirectory, refuse, within } from './util.mjs';
|
|
6
|
+
|
|
7
|
+
export function killGroup(pid, signal = 'SIGTERM') {
|
|
8
|
+
if (!Number.isInteger(pid) || pid < 2) return;
|
|
9
|
+
try { process.kill(-pid, signal); } catch { /* The process group already ended. */ }
|
|
10
|
+
}
|
|
11
|
+
let own = null;
|
|
12
|
+
export function processIdentity(pid) {
|
|
13
|
+
if (!Number.isInteger(pid) || pid < 2) return null;
|
|
14
|
+
if (pid === process.pid && own?.pid === pid) return own.identity;
|
|
15
|
+
const r = spawnSync('ps', ['-p', String(pid), '-o', 'lstart=', '-o', 'command='], { encoding: 'utf8' });
|
|
16
|
+
const identity = r.status === 0 ? r.stdout.trim() : null;
|
|
17
|
+
if (pid === process.pid && identity) own = { pid, identity };
|
|
18
|
+
return identity;
|
|
19
|
+
}
|
|
20
|
+
export function recoverProcess(record) {
|
|
21
|
+
if (!record?.pid || !record.identity) return 'interrupted';
|
|
22
|
+
if (processIdentity(record.pid) !== record.identity) return 'interrupted';
|
|
23
|
+
killGroup(record.pid, 'SIGKILL');
|
|
24
|
+
return 'stopped';
|
|
25
|
+
}
|
|
26
|
+
// Claude Code installs to ~/.local/bin, and Debian and Ubuntu put that folder on PATH only in
|
|
27
|
+
// login shells. ELU Code started by tmux, ssh, launchd or systemd would not find the lead. The
|
|
28
|
+
// folder is appended, so anything earlier on PATH still wins. Every process ELU Code starts
|
|
29
|
+
// (conductor, peer daemon, agent runs) inherits it.
|
|
30
|
+
export function addUserBin(env = process.env, home = os.homedir()) {
|
|
31
|
+
const folder = path.join(home, '.local', 'bin'), parts = (env.PATH ?? '').split(path.delimiter).filter(Boolean);
|
|
32
|
+
if (!path.isAbsolute(folder) || parts.includes(folder)) return false;
|
|
33
|
+
try { if (!fs.statSync(folder).isDirectory()) return false; } catch { return false; }
|
|
34
|
+
env.PATH = [...parts, folder].join(path.delimiter); return true;
|
|
35
|
+
}
|
|
36
|
+
// The environment an agent gets: a fixed allow-list, plus a vendor's own named keys
|
|
37
|
+
// (meta.envKeys) and overrides it sets, such as a private HOME under ELU Code's state.
|
|
38
|
+
const KEPT = /^(PATH|HOME|USER|LOGNAME|LANG|LC_ALL|LC_CTYPE|TERM|SHELL|TMPDIR|CODEX_HOME|CLAUDE_CONFIG_DIR|XDG_CONFIG_HOME|XDG_RUNTIME_DIR|SSL_CERT_FILE|SSL_CERT_DIR)$/;
|
|
39
|
+
const NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
40
|
+
export function agentEnv(env = process.env, extraKeys = [], overrides = {}) {
|
|
41
|
+
if (!Array.isArray(extraKeys) || extraKeys.some(key => !NAME.test(key))) refuse('Extra environment names must be upper-case words.');
|
|
42
|
+
const result = Object.fromEntries(Object.entries(env).filter(([key]) => KEPT.test(key) || extraKeys.includes(key)));
|
|
43
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
44
|
+
if (!NAME.test(key) || typeof value !== 'string' || /[\x00-\x1f]/.test(value)) refuse('Environment overrides must be upper-case names with plain text values.');
|
|
45
|
+
if (key === 'HOME' && (!path.isAbsolute(value) || !within(path.resolve(personalDirectory()), path.resolve(value)))) refuse("A private HOME must be inside ELU Code's personal directory.");
|
|
46
|
+
result[key] = value;
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
// hold: while it returns true (an approval is waiting for the owner) the role
|
|
51
|
+
// time limit does not advance; the output limit and abort still apply.
|
|
52
|
+
export async function runProcess({ bin, args, cwd, env, input, logFile, processFile, timeoutSeconds, onStart, signal, hold = null }) {
|
|
53
|
+
signal?.throwIfAborted();
|
|
54
|
+
noSymlinks(logFile);
|
|
55
|
+
const fd = fs.openSync(logFile, 'wx', 0o600);
|
|
56
|
+
const stderrFile = `${logFile}.stderr`;
|
|
57
|
+
noSymlinks(stderrFile);
|
|
58
|
+
const stderrFd = fs.openSync(stderrFile, 'wx', 0o600);
|
|
59
|
+
let child;
|
|
60
|
+
try { child = spawn(bin, args, { cwd, env, detached: true, stdio: ['pipe', fd, stderrFd] }); }
|
|
61
|
+
finally { fs.closeSync(fd); fs.closeSync(stderrFd); }
|
|
62
|
+
if (child.pid) {
|
|
63
|
+
const record = { pid: child.pid, identity: processIdentity(child.pid), bin: path.basename(bin) };
|
|
64
|
+
if (processFile) atomic(processFile, record);
|
|
65
|
+
onStart?.(record);
|
|
66
|
+
}
|
|
67
|
+
let timedOut = false;
|
|
68
|
+
let outputLimit = false;
|
|
69
|
+
const expire = () => { timedOut = true; killGroup(child.pid, 'SIGKILL'); };
|
|
70
|
+
let elapsed = 0, last = Date.now();
|
|
71
|
+
const held = () => { try { return hold() === true; } catch { return false; } };
|
|
72
|
+
const timer = hold ? setInterval(() => { const now = Date.now(); if (!held()) elapsed += now - last; last = now; if (elapsed >= timeoutSeconds * 1000) { clearInterval(timer); expire(); } }, 250) : setTimeout(expire, timeoutSeconds * 1000);
|
|
73
|
+
const outputTimer = setInterval(() => {
|
|
74
|
+
if (fs.statSync(logFile).size + fs.statSync(stderrFile).size > 16 * 1024 * 1024) { outputLimit = true; killGroup(child.pid, 'SIGKILL'); }
|
|
75
|
+
}, 500);
|
|
76
|
+
const stop = () => killGroup(child.pid, 'SIGKILL');
|
|
77
|
+
signal?.addEventListener('abort', stop, { once: true });
|
|
78
|
+
if (signal?.aborted) stop();
|
|
79
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, stop);
|
|
80
|
+
child.stdin.on('error', () => {});
|
|
81
|
+
child.stdin.end(input ?? '');
|
|
82
|
+
const result = await new Promise((resolve) => {
|
|
83
|
+
child.once('error', (error) => resolve({ code: null, error: error.message }));
|
|
84
|
+
child.once('close', (code, signal) => resolve({ code, signal }));
|
|
85
|
+
});
|
|
86
|
+
clearTimeout(timer); clearInterval(timer); clearInterval(outputTimer); stop();
|
|
87
|
+
signal?.removeEventListener('abort', stop);
|
|
88
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.off(signal, stop);
|
|
89
|
+
if (processFile) atomic(processFile, { pid: null, identity: null });
|
|
90
|
+
signal?.throwIfAborted();
|
|
91
|
+
return { ...result, timedOut, outputLimit, stderrFile };
|
|
92
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { atomic, dataBlock, noSymlinks, refuse } from './util.mjs';
|
|
4
|
+
import { clipNotes } from './memory.mjs';
|
|
5
|
+
import { approveRemoved, approveSaved, approvedAsItStands, trustedText } from './project-trust.mjs';
|
|
6
|
+
|
|
7
|
+
// A project's instructions and context, written by the owner in the Projects tab and kept
|
|
8
|
+
// in .elu-code/ beside the project's settings, so a team shares them through Git. Every
|
|
9
|
+
// lead turn gets them (projectPromptParts) once the owner approved their text on this
|
|
10
|
+
// machine: a save here approves what it writes, and text that arrived any other way is
|
|
11
|
+
// asked about first (lib/project-trust.mjs). Each file is at most 100 KB; a bigger save is
|
|
12
|
+
// refused with one line.
|
|
13
|
+
export const LIMIT = 100 * 1024;
|
|
14
|
+
const settingsDir = root => path.join(root, '.elu-code');
|
|
15
|
+
const settingsFile = (root, name) => path.join(settingsDir(root), name);
|
|
16
|
+
|
|
17
|
+
// Text as the owner typed it, with Windows line ends and control characters taken out.
|
|
18
|
+
function checkText(value, label) {
|
|
19
|
+
const text = String(value ?? '').replace(/\r\n?/g, '\n').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
|
|
20
|
+
if (Buffer.byteLength(withNewline(text)) > LIMIT) refuse(`Keep ${label} under 100 KB.`);
|
|
21
|
+
return text;
|
|
22
|
+
}
|
|
23
|
+
const withNewline = text => text.endsWith('\n') ? text : `${text}\n`;
|
|
24
|
+
// Empty text means he goes without: nothing stays in force, and the tab shows none again.
|
|
25
|
+
// The file is removed only when it is the text he approved; one that is not in use is not
|
|
26
|
+
// his text to clear, so it stays and is asked about.
|
|
27
|
+
function saveText(root, name, value, label) {
|
|
28
|
+
const text = checkText(value, label), file = settingsFile(root, name); noSymlinks(file);
|
|
29
|
+
if (!text.trim()) {
|
|
30
|
+
const mine = approvedAsItStands(root, name);
|
|
31
|
+
if (mine || trustedText(root, name) != null) approveRemoved(root, name);
|
|
32
|
+
if (mine) fs.unlinkSync(file);
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
approveSaved(root, name, withNewline(text)); atomic(file, withNewline(text)); return text;
|
|
36
|
+
}
|
|
37
|
+
export function saveInstructions(root, text) { return saveText(root, 'instructions.md', text, 'the instructions'); }
|
|
38
|
+
export function saveContext(root, text) { return saveText(root, 'context.md', text, 'the context'); }
|
|
39
|
+
|
|
40
|
+
// The text in force, which is what the Projects tab shows and edits: the file's when he
|
|
41
|
+
// approved it as it stands, else the text he approved last. A file he has not approved is
|
|
42
|
+
// never carried into the editor, so a save is his own text. With nothing in force it reads
|
|
43
|
+
// as empty; a symbolic link on the way is refused.
|
|
44
|
+
export function readProjectSettings(root) {
|
|
45
|
+
return { instructions: trustedText(root, 'instructions.md') ?? '', context: trustedText(root, 'context.md') ?? '' };
|
|
46
|
+
}
|
|
47
|
+
// What a lead turn gets after its instructions: the owner's instructions to follow and the
|
|
48
|
+
// context as data, each only as the owner approved it on this machine. Long text is
|
|
49
|
+
// clipped the way Claude Code memory is.
|
|
50
|
+
export function projectPromptParts(root) {
|
|
51
|
+
const { instructions, context } = readProjectSettings(root), parts = [];
|
|
52
|
+
if (instructions.trim()) parts.push(`Project instructions (owner):\n${clipNotes(instructions)}`);
|
|
53
|
+
if (context.trim()) parts.push(dataBlock('Project context', clipNotes(context)));
|
|
54
|
+
return parts;
|
|
55
|
+
}
|