@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/editor.mjs ADDED
@@ -0,0 +1,165 @@
1
+ import blessed from 'blessed';
2
+
3
+ // A cursor for the message box: arrows, word and line moves, and a click that puts the cursor
4
+ // where you clicked. blessed's textarea only appends at the end.
5
+
6
+ const CONTROL = /[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]/g;
7
+ const charAt = (value, index) => String.fromCodePoint(value.codePointAt(index));
8
+ const before = (value, index) => index >= 2 && /[\udc00-\udfff]/.test(value[index - 1]) && /[\ud800-\udbff]/.test(value[index - 2]) ? index - 2 : Math.max(0, index - 1);
9
+ const after = (value, index) => Math.min(value.length, index + (index < value.length ? charAt(value, index).length : 0));
10
+ const defaultWidth = ch => ch === '\t' ? 4 : Math.max(0, blessed.unicode.charWidth(ch, 0));
11
+ const widthOf = (text, charWidth) => { let total = 0; for (const ch of text) total += charWidth(ch); return total; };
12
+
13
+ // Rows of the value at this width. Each row shows value.slice(start, end); next is where the
14
+ // following row starts: end + 1 after a newline or a space that would not fit, else end.
15
+ export function layout(value, width, charWidth = defaultWidth) {
16
+ width = Math.max(1, width);
17
+ const rows = []; let start = 0, col = 0, space = -1, index = 0;
18
+ while (index < value.length) {
19
+ const ch = charAt(value, index);
20
+ if (ch === '\n') { rows.push({ start, end: index, next: index + 1 }); start = index + 1; col = 0; space = -1; index++; continue; }
21
+ const size = charWidth(ch);
22
+ if (col + size > width && index > start) {
23
+ if (ch === ' ') { rows.push({ start, end: index, next: index + 1 }); start = index + 1; col = 0; space = -1; index++; continue; }
24
+ const cut = space >= start ? space + 1 : index;
25
+ rows.push({ start, end: cut, next: cut }); start = cut; space = -1; col = widthOf(value.slice(start, index), charWidth);
26
+ continue;
27
+ }
28
+ if (ch === ' ') space = index;
29
+ col += size; index += ch.length;
30
+ }
31
+ rows.push({ start, end: value.length, next: value.length });
32
+ return rows;
33
+ }
34
+ // The row and column the cursor shows at. At a soft wrap it shows at the start of the next row.
35
+ export function position(value, rows, index, charWidth = defaultWidth) {
36
+ for (let row = 0; row < rows.length; row++) {
37
+ const r = rows[row];
38
+ if (index < r.start) continue;
39
+ if (index < r.end || row === rows.length - 1 || index === r.end && r.next > r.end) return { row, col: widthOf(value.slice(r.start, Math.min(index, r.end)), charWidth) };
40
+ }
41
+ return { row: 0, col: 0 };
42
+ }
43
+ // The index at a row and column: the boundary at or before that column.
44
+ export function indexAt(value, rows, row, col, charWidth = defaultWidth) {
45
+ const r = rows[Math.max(0, Math.min(rows.length - 1, row))];
46
+ let index = r.start, used = 0;
47
+ while (index < r.end) { const ch = charAt(value, index), size = charWidth(ch); if (used + size > col) break; used += size; index += ch.length; }
48
+ return index;
49
+ }
50
+ const lineStart = (value, index) => value.lastIndexOf('\n', index - 1) + 1;
51
+ const lineEnd = (value, index) => { const end = value.indexOf('\n', index); return end < 0 ? value.length : end; };
52
+ const word = ch => /[\p{L}\p{N}_]/u.test(ch);
53
+ function wordBefore(value, index) {
54
+ while (index > 0 && !word(charAt(value, before(value, index)))) index = before(value, index);
55
+ while (index > 0 && word(charAt(value, before(value, index)))) index = before(value, index);
56
+ return index;
57
+ }
58
+ function wordAfter(value, index) {
59
+ while (index < value.length && !word(charAt(value, index))) index = after(value, index);
60
+ while (index < value.length && word(charAt(value, index))) index = after(value, index);
61
+ return index;
62
+ }
63
+
64
+ // One key on the editor state { value, cursor, goal }. Returns the next state, or null when the
65
+ // key is not an edit (Enter, Escape and Tab stay with the caller).
66
+ export function editKey(state, ch, key = {}, view = {}) {
67
+ const { value, cursor } = state, name = key.name;
68
+ const rows = view.rows ?? layout(value, view.width ?? 80, view.charWidth), charWidth = view.charWidth ?? defaultWidth;
69
+ const at = (next, goal = null) => ({ value, cursor: Math.max(0, Math.min(value.length, next)), goal });
70
+ const cut = (from, to) => ({ value: value.slice(0, from) + value.slice(to), cursor: from, goal: null });
71
+ const vertical = step => {
72
+ const here = position(value, rows, cursor, charWidth), goal = state.goal ?? here.col, row = here.row + step;
73
+ if (row < 0) return at(0, goal);
74
+ if (row >= rows.length) return at(value.length, goal);
75
+ return at(indexAt(value, rows, row, goal, charWidth), goal);
76
+ };
77
+ if (key.meta && (name === 'b' || name === 'left') || key.ctrl && name === 'left') return at(wordBefore(value, cursor));
78
+ if (key.meta && (name === 'f' || name === 'right') || key.ctrl && name === 'right') return at(wordAfter(value, cursor));
79
+ if (key.meta && name === 'backspace' || key.ctrl && name === 'w') return cut(wordBefore(value, cursor), cursor);
80
+ if (key.meta && name === 'd') return cut(cursor, wordAfter(value, cursor));
81
+ if (name === 'left' || key.ctrl && name === 'b') return at(before(value, cursor));
82
+ if (name === 'right' || key.ctrl && name === 'f') return at(after(value, cursor));
83
+ if (name === 'up' || key.ctrl && name === 'p') return vertical(-1);
84
+ if (name === 'down' || key.ctrl && name === 'n') return vertical(1);
85
+ if (name === 'home' || key.ctrl && name === 'a') return at(lineStart(value, cursor));
86
+ if (name === 'end' || key.ctrl && name === 'e') return at(lineEnd(value, cursor));
87
+ if (name === 'backspace') return cut(before(value, cursor), cursor);
88
+ if (name === 'delete' || key.ctrl && name === 'd') return cut(cursor, after(value, cursor));
89
+ if (key.ctrl && name === 'u') return cut(lineStart(value, cursor), cursor);
90
+ if (key.ctrl && name === 'k') { const end = lineEnd(value, cursor); return cut(cursor, end === cursor ? after(value, cursor) : end); }
91
+ if (['enter', 'return', 'escape', 'tab'].includes(name) && key.sequence !== '\n') return null;
92
+ const text = name === 'enter' ? '\n' : typeof ch === 'string' ? ch.replace(CONTROL, '') : '';
93
+ if (!text || key.ctrl || key.meta) return key.ctrl || key.meta ? state : null;
94
+ return { value: value.slice(0, cursor) + text + value.slice(cursor), cursor: cursor + text.length, goal: null };
95
+ }
96
+
97
+ // Gives a blessed textarea the cursor: its own wrapping, scrolling that follows the cursor,
98
+ // the terminal cursor at that spot, and a click that moves it there.
99
+ export function editable(field) {
100
+ const screen = field.screen;
101
+ const charWidth = ch => ch === '\t' ? screen.tabc.length : Math.max(0, blessed.unicode.charWidth(ch, 0));
102
+ field.cursor = field.value.length; field.goal = null;
103
+ let laid = null;
104
+ // blessed wraps a textarea one column short, leaving room for the cursor; rows here match it.
105
+ const width = () => Math.max(1, field.width - field.iwidth - 1 - (field.scrollbar ? 1 : 0));
106
+ const rows = () => {
107
+ const w = width();
108
+ if (laid?.value !== field.value || laid.width !== w) laid = { value: field.value, width: w, rows: layout(field.value, w, charWidth) };
109
+ return laid.rows;
110
+ };
111
+ field.editorView = () => ({ rows: rows(), width: width(), charWidth });
112
+ // Typing keeps the cursor; a value set from outside puts it at the end.
113
+ field.setValue = function(value) {
114
+ if (value != null && value !== this.value) { this.cursor = value.length; this.goal = null; }
115
+ if (value != null) this.value = value;
116
+ this.cursor = Math.max(0, Math.min(this.cursor, this.value.length));
117
+ const list = rows(), content = list.map(row => this.value.slice(row.start, row.end)).join('\n');
118
+ if (this._editorContent !== content) { this._editorContent = content; this.setContent(content); }
119
+ const height = Math.max(1, this.height - this.iheight), row = position(this.value, list, this.cursor, charWidth).row;
120
+ let base = this.childBase || 0;
121
+ if (row < base) base = row; else if (row >= base + height) base = row - height + 1;
122
+ this.childBase = Math.max(0, Math.min(base, Math.max(0, list.length - height))); this.childOffset = 0;
123
+ this._updateCursor();
124
+ };
125
+ field.clearValue = field.clearInput = function() { return this.setValue(''); };
126
+ field._updateCursor = function(get) {
127
+ if (screen.focused !== this) return;
128
+ const lpos = get ? this.lpos : this._getCoords(); if (!lpos) return;
129
+ const { row, col } = position(this.value, rows(), this.cursor, charWidth), program = screen.program;
130
+ const y = lpos.yi + this.itop + Math.max(0, Math.min(row - (this.childBase || 0), lpos.yl - lpos.yi - this.iheight - 1));
131
+ const x = Math.min(lpos.xl - 1, lpos.xi + this.ileft + col);
132
+ if (y !== program.y || x !== program.x) program.cup(y, x);
133
+ };
134
+ // Backspace or Delete with text highlighted in the box removes that text. field.selection
135
+ // gives the highlighted cells { from, to } inside the field, or null.
136
+ field._listener = function(ch, key = {}) {
137
+ if (key.name === 'escape') return this._done?.(null, null);
138
+ const highlighted = ['backspace', 'delete'].includes(key.name) && !key.ctrl && !key.meta ? field.selection?.() : null;
139
+ const next = highlighted ? cutCells(highlighted.from, highlighted.to) : editKey({ value: this.value, cursor: this.cursor, goal: this.goal }, ch, key, field.editorView());
140
+ if (!next) return;
141
+ const changed = next.value !== this.value || next.cursor !== this.cursor;
142
+ this.value = next.value; this.cursor = next.cursor; this.goal = next.goal;
143
+ if (changed) { this.setValue(); screen.render(); }
144
+ };
145
+ // The text between two screen cells, for copying a selection.
146
+ const cellIndex = (x, y, lpos = field.lpos) => {
147
+ if (!lpos) return 0;
148
+ return indexAt(field.value, rows(), y - lpos.yi - field.itop + (field.childBase || 0), Math.max(0, x - lpos.xi - field.ileft), charWidth);
149
+ };
150
+ const cellRange = (from, to) => { const a = cellIndex(from.x, from.y), b = cellIndex(to.x + 1, to.y); return [Math.min(a, b), Math.max(a, b)]; };
151
+ field.selectText = (from, to) => { const [a, b] = cellRange(from, to); return field.value.slice(a, b); };
152
+ const cutCells = (from, to) => { const [a, b] = cellRange(from, to); return { value: field.value.slice(0, a) + field.value.slice(b), cursor: a, goal: null }; };
153
+ // A click puts the cursor at that character; past a wrapped row's end it stays on that row.
154
+ field.on('click', data => {
155
+ if (data.button !== 'left' || !field.lpos) return;
156
+ const list = rows(), row = Math.max(0, Math.min(list.length - 1, data.y - field.lpos.yi - field.itop + (field.childBase || 0)));
157
+ let index = cellIndex(data.x, data.y);
158
+ if (index === list[row].end && list[row].next === list[row].end && index > list[row].start && row < list.length - 1) index = before(field.value, index);
159
+ field.cursor = index; field.goal = null;
160
+ if (screen.focused !== field) field.focus();
161
+ if (!field._reading) field.readInput?.();
162
+ field.setValue(); screen.render();
163
+ });
164
+ return field;
165
+ }
package/lib/format.mjs ADDED
@@ -0,0 +1,51 @@
1
+ import {quote}from'./util.mjs';
2
+ import {VENDOR_NAMES}from'./vendors.mjs';
3
+ import {botOfKey}from'./bots.mjs';
4
+ import {ADAPTERS}from'./adapters/index.mjs';
5
+ import {uncheckedText}from'./conformance.mjs';
6
+ const names={...VENDOR_NAMES,opencode:'OpenCode',cursor:'Cursor CLI'};
7
+ // The session's own run is the model to a person; the config file still calls it lead.
8
+ const shown=name=>name==='lead'?'model':name;
9
+ // Google's terms on wrapping agy: shown under every Antigravity sign-in row.
10
+ export const ANTIGRAVITY_FAQ="Google's FAQ: wrapping agy in third-party agents is not supported on consumer plans. antigravity.google/docs/faq";
11
+ // What a vendor row still needs, one line each: the owner signs in with its own CLI; the
12
+ // one-time check of a vendor designed from its docs starts by itself.
13
+ export function vendorLines(tool){
14
+ const lines=[],hint=ADAPTERS[tool.vendor]?.meta.signInHint;
15
+ if(tool.signedIn===false&&hint){lines.push(...hint.split('\n'));if(tool.vendor==='agy'&&!hint.includes(ANTIGRAVITY_FAQ))lines.push(ANTIGRAVITY_FAQ);}
16
+ if(tool.conformant===false&&tool.available!==false&&tool.supported!==false)lines.push(uncheckedText(tool.vendor,{version:tool.release??null}));
17
+ return lines;
18
+ }
19
+ export function formatResult(value,command=''){
20
+ if(value.help)return value.help;
21
+ const lines=[];
22
+ if(value.preview)lines.push('Preview only. Nothing ran and no model credit was used.');
23
+ if(value.goal)lines.push(`Task: ${value.goal}`);
24
+ if(value.initialized)lines.push(value.adopted?'ELU Code adopted the committed configuration.':'ELU Code is ready.');
25
+ if(value.uninstalled)lines.push('ELU Code local state was removed.');
26
+ if(value.message)lines.push(value.message);
27
+ for(const line of Array.isArray(value.lines)?value.lines:[])lines.push(String(line));
28
+ if(value.state)lines.push(`Task ${value.id}: ${value.state}`,value.result?.summary??'');
29
+ if(value.sandboxHelper)lines.push(value.sandboxHelper.working?'Sandbox helper: outside file writes were contained; network access was denied.':`Sandbox helper: ${value.sandboxHelper.message}`);
30
+ const vendors=Array.isArray(value.vendors)?value.vendors:value.vendors?Object.values(value.vendors).filter(Boolean):[];
31
+ for(const tool of vendors){lines.push(`${names[tool.vendor]??tool.vendor}: ${tool.available===false?'not installed':tool.version??tool.support??'unknown'}${tool.versionStatus?` (${tool.versionStatus})`:''}${tool.signedIn===false?' — signed out':''}${tool.reason?` — ${tool.reason}`:''}`);for(const line of vendorLines(tool))lines.push(` ${line}`);}
32
+ for(const row of value.unsupportedVendors??[])lines.push(`${names[row.vendor]}: ${row.support}`);
33
+ // Only the lead entry runs. An older config may still list review entries; bots review now.
34
+ const lead=value.config?.roles?.lead;if(lead)lines.push(`model: ${names[lead.vendor]??lead.vendor}; ${lead.access}; allowed commands: ${lead.commands.join(', ')||'none'}`);
35
+ for(const check of value.config?.checks??[])lines.push(`Check ${check.name}: ${check.command.join(' ')}`);
36
+ for(const file of value.hooks??value.git?.hooks??[])lines.push(`Git hook file: ${file}`);
37
+ for(const role of value.roles??[]){lines.push(`${shown(role.name)}: ${names[role.vendor]??role.vendor}`);for(const rule of role.rules)lines.push(` ${rule.rule}: ${rule.enforcement}`);if(role.refusal)lines.push(` Refused: ${role.refusal}`);}
38
+ for(const task of value.tasks??[]){lines.push(`Task ${task.id}: ${task.state} — ${task.goal}`);for(const run of task.runs??[])lines.push(` ${botOfKey(run.role)?`@${botOfKey(run.role)}`:shown(run.role)}: ${names[run.vendor]??run.vendor} ${run.version??''} (${run.versionStatus??'version not recorded'})`);}
39
+ if(value.tasks?.length===0)lines.push('No tasks yet.');
40
+ for(const result of value.results??value.evidence?.results??[])lines.push(`${result.name}: ${result.status}`);
41
+ for(const file of value.writes??[])lines.push(`Will write: ${file}`);
42
+ for(const file of value.trackedDeletions??[])lines.push(`Tracked deletion: ${file}`);
43
+ if(value.command)lines.push(value.command);
44
+ if(value.commit)lines.push(`Commit: ${value.commit}`);
45
+ if(value.destination)lines.push(`Push destination: ${value.destination.name} ${value.destination.ref}`);
46
+ if(value.body)lines.push('',value.body);
47
+ if(value.pr)lines.push(`Draft PR: ${value.pr}`);
48
+ if(value.nextCommand)lines.push(`To continue: ${value.nextCommand}`);
49
+ else if(value.needsConfirmation||value.preview||value.files)lines.push(`To continue: elu ${command==='run'?`run ${quote(value.goal??'Describe your task')}`:command} --yes`);
50
+ return lines.filter(line=>line!=='').join('\n')+'\n';
51
+ }
package/lib/git.mjs ADDED
@@ -0,0 +1,173 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execute, refuse, noSymlinks, hash, findBin } from './util.mjs';
4
+
5
+ export function git(root, args, options = {}) {
6
+ return execute('git', ['--no-pager', '-C', root, ...args], { ...options, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', ...options.env } });
7
+ }
8
+ export function prepareGit(repo,root,args){
9
+ if(!['add','worktree'].includes(args[0]))refuse('Only Git preparation can use an empty hook directory.');
10
+ const hooks=path.join(repo.state,'empty-hooks');noSymlinks(hooks);fs.mkdirSync(hooks,{recursive:true,mode:0o700});
11
+ if(fs.readdirSync(hooks).length)refuse('The Git preparation hook directory is not empty.');
12
+ return git(root,['-c',`core.hooksPath=${hooks}`,...args]);
13
+ }
14
+ export function located(root, common) {
15
+ return { root, common, state: path.join(common, 'elu-code'), worktrees: path.join(path.dirname(root), `${path.basename(root)}.elu-code`) };
16
+ }
17
+ export function repository(cwd) {
18
+ const root = fs.realpathSync(git(cwd, ['rev-parse', '--show-toplevel']).stdout.trim());
19
+ return located(root, fs.realpathSync(path.resolve(root, git(root, ['rev-parse', '--git-common-dir']).stdout.trim())));
20
+ }
21
+ // Views show the branch on every redraw. Reading HEAD avoids starting a Git process each time.
22
+ export function currentBranch(root) {
23
+ try {
24
+ const dot = path.join(root, '.git'), stat = fs.lstatSync(dot);
25
+ const dir = stat.isDirectory() ? dot : stat.isFile() ? path.resolve(root, /^gitdir: (.+)$/m.exec(fs.readFileSync(dot, 'utf8'))?.[1].trim() ?? '') : null;
26
+ const value = dir && fs.lstatSync(path.join(dir, 'HEAD')).isFile() ? fs.readFileSync(path.join(dir, 'HEAD'), 'utf8').trim() : '';
27
+ if (value.startsWith('ref: refs/heads/')) return value.slice(16);
28
+ if (/^[0-9a-f]{40,64}$/.test(value)) return '';
29
+ } catch { /* Unusual layouts fall back to Git. */ }
30
+ return git(root, ['branch', '--show-current']).stdout.trim();
31
+ }
32
+ export function findRepository(cwd) {
33
+ if (!findBin('git')) return null;
34
+ const result = git(cwd, ['rev-parse', '--show-toplevel'], { allowFailure: true });
35
+ if (result.status === 0) return repository(cwd);
36
+ if (/not a git repository/i.test(result.stderr)) return null;
37
+ refuse('The project history could not be read.', { reason: result.stderr.trim().slice(0, 1000) });
38
+ }
39
+ export function head(root) { return git(root, ['rev-parse', '--verify', 'HEAD']).stdout.trim(); }
40
+ // The commit a named local branch points at (a task placed on a branch bases on it).
41
+ export function branchHead(root, branch) {
42
+ const result = git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], { allowFailure: true });
43
+ if (result.status !== 0) refuse(`Branch not found: ${branch}`);
44
+ return result.stdout.trim();
45
+ }
46
+ export function baseFile(root, base, file, { optional = false } = {}) {
47
+ const r = git(root, ['show', `${base}:${file}`], { allowFailure: optional });
48
+ return r.status === 0 ? r.stdout : null;
49
+ }
50
+ export function tracked(root, base) {
51
+ return git(root, ['ls-tree', '-r', '-z', '--name-only', base]).stdout.split('\0').filter(Boolean);
52
+ }
53
+ function hookSource(repo, root) {
54
+ const configured = git(root, ['config', '--path', '--get', 'core.hooksPath'], { allowFailure: true }).stdout.trim();
55
+ const active = configured ? path.resolve(root, configured) : path.join(repo.common, 'hooks');
56
+ const relative = path.relative(root, active);
57
+ const inside = configured && !path.isAbsolute(configured) && relative && !relative.startsWith('..') && relative.split(path.sep)[0]!=='.git' && !path.isAbsolute(relative);
58
+ const source = inside ? path.join(root, relative.split(path.sep)[0]) : active;
59
+ const files = [];
60
+ function walk(dir) {
61
+ if (!fs.existsSync(dir)) return;
62
+ noSymlinks(dir);
63
+ for (const name of fs.readdirSync(dir).sort()) {
64
+ const file=path.join(dir,name); noSymlinks(file);const stat=fs.statSync(file);
65
+ if(stat.isDirectory())walk(file);
66
+ else if(stat.isFile()&&!name.endsWith('.sample'))files.push([path.relative(source,file),hash(fs.readFileSync(file)),stat.mode&0o777]);
67
+ }
68
+ }
69
+ walk(source);
70
+ return { source, active, relative: inside ? relative.split(path.sep)[0] : null, files, digest: hash(JSON.stringify({ configured, files })) };
71
+ }
72
+ export function assertGitSafe(repo, { worktree = repo.root, expectedHooks } = {}) {
73
+ const fsmonitor = git(repo.root, ['config', '--get', 'core.fsmonitor'], { allowFailure: true }).stdout.trim();
74
+ if (fsmonitor && fsmonitor !== 'false') refuse('Git filesystem monitors can execute programs. Disable the monitor before starting a checked task.');
75
+ const source=hookSource(repo,worktree);
76
+ const defaultGuard=path.join(repo.common,'hooks','pre-push');noSymlinks(defaultGuard);
77
+ const hookDigest=hash(JSON.stringify([source.digest,fs.existsSync(defaultGuard)?hash(fs.readFileSync(defaultGuard)):null]));
78
+ if(expectedHooks && hookDigest!==expectedHooks)refuse('Git hooks changed after the preview. Review the hooks before continuing.');
79
+ const globalAttributes = git(worktree, ['config', '--path', '--get', 'core.attributesFile'], { allowFailure: true }).stdout.trim();
80
+ const attrs = git(worktree, ['ls-files','-z', '--cached', '--others', '--exclude-standard', '--', '.gitattributes', '**/.gitattributes', '.gitmodules']).stdout.split('\0').filter(Boolean);
81
+ if(attrs.includes('.gitmodules')||fs.existsSync(path.join(worktree,'.gitmodules')))refuse('Submodules are not supported for checked tasks.');
82
+ const defaults=['GIT_ATTR_SYSTEM','GIT_ATTR_GLOBAL'].map(name=>git(worktree,['var',name],{allowFailure:true})).filter(row=>row.status===0).map(row=>row.stdout.trim());
83
+ for(const file of new Set([path.join(repo.common,'info/attributes'),path.join(worktree,'.gitattributes'),...attrs.map(name=>path.join(worktree,name)),...defaults,...(globalAttributes?[path.resolve(worktree,globalAttributes)]:[])])){
84
+ if(!fs.existsSync(file))continue;if(file.startsWith(worktree+path.sep))noSymlinks(file);
85
+ for(const line of fs.readFileSync(file,'utf8').split('\n')){
86
+ if(/^\s*(?:#|$)/.test(line))continue;
87
+ const tokens=line.match(/"(?:[^"\\]|\\.)*"|\S+/g)??[];
88
+ for(const token of tokens.slice(1)){
89
+ const match=/^(filter|diff|merge)=(.+)$/.exec(token);if(!match)continue;
90
+ const [,kind,name]=match;if(kind==='merge'&&['text','binary','union'].includes(name))continue;
91
+ const keys=kind==='filter'?['clean','smudge','process']:kind==='diff'?['command','textconv']:['driver'];
92
+ if(keys.some(key=>git(worktree,['config','--get',`${kind}.${name}.${key}`],{allowFailure:true}).stdout.trim()))refuse(`Git attribute ${kind}=${name} enables an executable driver.`);
93
+ }
94
+ }
95
+ }
96
+ return { hooks:source.files.map(([file])=>path.join(source.source,file)),hookDigest,protectedPaths:source.relative?[source.relative]:[],hookSource:source };
97
+ }
98
+ export function assertTaskGitSafe(repo,task){
99
+ assertGitSafe(repo,{expectedHooks:task.hookDigest});
100
+ return assertGitSafe(repo,{worktree:task.worktree,expectedHooks:task.worktreeHookDigest??task.hookDigest});
101
+ }
102
+ export function snapshotHooks(repo, task, helper=findBin('codex')) {
103
+ const inspected=assertGitSafe(repo,{expectedHooks:task.hookDigest});
104
+ assertTaskGitSafe(repo,task);
105
+ const folder=path.join(repo.state,'tasks',task.id,'hooks');noSymlinks(folder);fs.mkdirSync(folder,{recursive:true,mode:0o700});
106
+ // Keep push guards, including the common-directory guard when a hook manager is configured.
107
+ const sources=[inspected.hookSource];
108
+ const defaultGuard=path.join(repo.common,'hooks','pre-push');
109
+ const guards=[...new Set([...sources.map(s=>path.join(s.active,'pre-push')),defaultGuard])].filter(file=>fs.existsSync(file));
110
+ if(guards.length&&!helper)refuse('A Codex sandbox helper is required to run pre-push guards safely.');
111
+ const copied=[];
112
+ for(const [index,guard] of guards.entries()){
113
+ noSymlinks(guard);const source=sources.find(s=>guard.startsWith(s.source+path.sep));const from=source?.source??path.dirname(guard);const to=path.join(folder,`source-${index}`);
114
+ fs.cpSync(from,to,{recursive:true,filter:file=>!file.endsWith('.sample')});
115
+ copied.push(path.join(to,path.relative(from,guard)));
116
+ }
117
+ const dispatcher=path.join(folder,'pre-push');
118
+ fs.writeFileSync(dispatcher,`#!${process.execPath}\nimport fs from 'node:fs';import os from 'node:os';import path from 'node:path';import{spawnSync}from'node:child_process';import{sandboxArgs}from ${JSON.stringify(new URL('./sandbox.mjs',import.meta.url).href)};import{agentEnv,killGroup}from ${JSON.stringify(new URL('./process.mjs',import.meta.url).href)};const input=fs.readFileSync(0);for(const hook of ${JSON.stringify(copied)}){const scratch=fs.mkdtempSync(path.join(${JSON.stringify(folder)},'tmp-'));let result;try{fs.mkdirSync(path.join(scratch,'codex-home'),{mode:0o700});fs.mkdirSync(path.join(scratch,'tmp'),{mode:0o700});const args=sandboxArgs({helper:${JSON.stringify(helper)},cwd:process.cwd(),writablePaths:[],scratch,program:hook,args:process.argv.slice(2),readPaths:[${JSON.stringify(repo.common)},path.join(process.cwd(),'.git')],deniedPaths:['.ssh','.aws','.config','.claude','.codex','.netrc','.npmrc'].map(name=>path.join(os.homedir(),name))});result=spawnSync(${JSON.stringify(helper)},args,{input,env:{...agentEnv(),CODEX_HOME:path.join(scratch,'codex-home'),TMPDIR:path.join(scratch,'tmp'),GIT_OPTIONAL_LOCKS:'0',GIT_TERMINAL_PROMPT:'0'},stdio:['pipe','inherit','inherit'],detached:true,timeout:120000});if(result.pid)killGroup(result.pid,'SIGKILL');}finally{fs.rmSync(scratch,{recursive:true,force:true});}if(result.status!==0)process.exit(result.status??1);}\n`,{mode:0o700});
119
+ return folder;
120
+ }
121
+ export function hooksSnapshotDigest(folder) {
122
+ noSymlinks(folder);const rows=[];
123
+ function visit(dir){for(const name of fs.readdirSync(dir).sort()){const file=path.join(dir,name);noSymlinks(file);const stat=fs.statSync(file);if(stat.isDirectory())visit(file);else rows.push([path.relative(folder,file),stat.mode&0o777,hash(fs.readFileSync(file))]);}}
124
+ visit(folder);return hash(JSON.stringify(rows));
125
+ }
126
+ export function assertPublishingHooks(repo,task){
127
+ const expected=path.join(repo.state,'tasks',task.id,'hooks');
128
+ if(task.hooksDirectory!==expected||!task.hooksSnapshotDigest||hooksSnapshotDigest(expected)!==task.hooksSnapshotDigest)refuse('The saved hooks are missing or changed. Start a new task before publishing.');
129
+ assertTaskGitSafe(repo,task);
130
+ return expected;
131
+ }
132
+ // A task's changes against its base, for every place. In the owner's real
133
+ // checkout (place here) the ignored files were there before the task, so they are not
134
+ // counted; a task worktree starts clean, so anything ignored there is the task's output.
135
+ export function taskChanges(task) { return changed(task.worktree, task.base, { ignored: task.place?.kind !== 'here' }); }
136
+ export function changed(root, base, { ignored: withIgnored = true } = {}) {
137
+ const args = ['diff', '--no-ext-diff', '--no-textconv', '--no-renames'];
138
+ const names = git(root, [...args, '--name-only', '-z', base, '--']).stdout.split('\0').filter(Boolean);
139
+ const untracked = git(root, ['ls-files', '--others', '--exclude-standard', '-z']).stdout.split('\0').filter(Boolean);
140
+ const ignored = withIgnored ? git(root, ['ls-files', '--others', '--ignored', '--exclude-standard', '-z']).stdout.split('\0').filter(Boolean) : [];
141
+ const paths = [...new Set([...names, ...untracked, ...ignored])].sort();
142
+ const counts = git(root, [...args, '--numstat', '-z', base, '--']).stdout.split('\0').filter(Boolean);
143
+ let lines = 0;
144
+ let binary = false;
145
+ for (const row of counts) {
146
+ const [added, removed] = row.split('\t');
147
+ if (added === '-' || removed === '-') binary = true;
148
+ else lines += Number(added) + Number(removed);
149
+ }
150
+ const entries = [];
151
+ for (const file of paths) {
152
+ const full = path.join(root, file);
153
+ let bytes = null;
154
+ let mode = null;
155
+ try {
156
+ noSymlinks(full);
157
+ const stat = fs.statSync(full);
158
+ if (!stat.isFile()) refuse(`Changed path is not a regular file: ${file}`);
159
+ if (stat.size > 4 * 1024 * 1024) refuse(`Changed file is too large to inspect: ${file}`);
160
+ bytes = fs.readFileSync(full); mode = stat.mode & 0o777;
161
+ if (untracked.includes(file) || ignored.includes(file)) {
162
+ if (bytes.includes(0)) binary = true;
163
+ else lines += bytes.length ? bytes.toString('utf8').split('\n').length - (bytes.at(-1) === 10 ? 1 : 0) : 0;
164
+ }
165
+ } catch (e) { if (e.code !== 'ENOENT') throw e; }
166
+ entries.push([file, bytes ? hash(bytes) : null, mode]);
167
+ }
168
+ return { paths, lines, binary, ignored, digest: hash(JSON.stringify(entries)) };
169
+ }
170
+ export function diff(root, base) {
171
+ const value = git(root, ['diff', '--no-ext-diff', '--no-textconv', '--no-renames', base, '--']).stdout;
172
+ return value;
173
+ }
package/lib/init.mjs ADDED
@@ -0,0 +1,129 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { defaultConfig,parseConfig } from './config.mjs';
4
+ import { stringifyYaml } from './yaml.mjs';
5
+ import { atomic, hash, mkdir, noSymlinks, readJson, refuse } from './util.mjs';
6
+ import { NOT_APPROVED, approveRemoved, approveSaved, trustedText } from './project-trust.mjs';
7
+ import { detect } from './detect.mjs';
8
+ import { event, tasks } from './state.mjs';
9
+ import { git, assertGitSafe,baseFile,head } from './git.mjs';
10
+ import { processIdentity } from './process.mjs';
11
+ import { checkPlace } from './modes.mjs';
12
+
13
+ const BLOCK = '<!-- elu-code:start -->\nUse the task policy in .elu-code/config.yaml. Leave policy and check definitions unchanged.\nOnly the owner may approve publishing.\n<!-- elu-code:end -->';
14
+ const IMPORT = '@.elu-code/config.yaml';
15
+ export function inferChecks(root){
16
+ const file=path.join(root,'package.json');noSymlinks(file);
17
+ if(fs.existsSync(file)){
18
+ let pkg;try{pkg=JSON.parse(fs.readFileSync(file,'utf8'));}catch{refuse('package.json is not valid JSON. Fix it before init.');}
19
+ const manager=/^(pnpm|yarn|bun)@/.exec(pkg.packageManager??'')?.[1]??'npm';
20
+ return ['test','build'].filter(name=>typeof pkg.scripts?.[name]==='string'&&pkg.scripts[name].trim()).map(name=>({name:name==='test'?'tests':'build',command:name==='test'?[manager,'test']:[manager,'run',name]}));
21
+ }
22
+ if(fs.existsSync(path.join(root,'go.mod')))return[{name:'tests',command:['go','test','./...']}];
23
+ if(fs.existsSync(path.join(root,'Cargo.toml')))return[{name:'tests',command:['cargo','test']}];
24
+ if(['pytest.ini','conftest.py'].some(file=>fs.existsSync(path.join(root,file))))return[{name:'tests',command:['python3','-m','pytest']}];
25
+ return [];
26
+ }
27
+ export function initPlan(repo, proposedConfig) {
28
+ noSymlinks(path.join(repo.root, '.elu-code'));
29
+ const manifestFile = path.join(repo.state, 'manifest.json');
30
+ if (fs.existsSync(manifestFile)) {
31
+ if (fs.existsSync(path.join(repo.root, '.elu-code/config.yaml'))) return { initialized: true, writes: [] };
32
+ const previous = readJson(manifestFile);
33
+ if (previous.version !== 1 || !Array.isArray(previous.files) || previous.files.some(file => !['.elu-code/config.yaml', 'AGENTS.md', 'CLAUDE.md'].includes(file.path))) refuse('Project setup history is invalid. Review it before restoring settings.');
34
+ const committed = baseFile(repo.root, head(repo.root), '.elu-code/config.yaml', { optional: true });
35
+ const config = committed ? parseConfig(committed) : proposedConfig ? parseConfig(stringifyYaml(proposedConfig)) : defaultConfig();
36
+ if (!committed && !proposedConfig) config.checks = inferChecks(repo.root);
37
+ const file = { path: '.elu-code/config.yaml', content: stringifyYaml(config), kind: 'file' };
38
+ return { initialized: false, config, fromHistory: Boolean(committed), files: [file], keepFiles: previous.files.filter(row => row.path !== file.path), worktrees: previous.worktrees, writes: [path.join(repo.root, file.path), manifestFile], vendors: detect(repo.root).map(({ bin, ...value }) => value), message: 'Restore the missing project settings. Existing tasks and marked instructions are kept.' };
39
+ }
40
+ const existing=path.join(repo.root,'.elu-code/config.yaml');noSymlinks(existing);
41
+ if(fs.existsSync(existing)){
42
+ const text=fs.readFileSync(existing,'utf8');if(baseFile(repo.root,head(repo.root),'.elu-code/config.yaml',{optional:true})!==text)refuse('Commit the existing .elu-code/config.yaml before adopting it.');
43
+ // A settings file that came with the folder is adopted only as the owner approved it on this machine.
44
+ if(trustedText(repo.root,'config.yaml')!==text)refuse(NOT_APPROVED);
45
+ return{initialized:false,adopted:true,adoptedHash:hash(text),config:parseConfig(text),files:[],writes:[repo.state],vendors:detect(repo.root).map(({bin,...row})=>row),message:'Use the committed configuration and create local run state.'};
46
+ }
47
+ const config = proposedConfig ? parseConfig(stringifyYaml(proposedConfig)) : defaultConfig();
48
+ // The scripts found in the folder become checks, which run inside the check sandbox. The lead
49
+ // gets no shell commands from them: roles.lead.commands holds only what the owner writes.
50
+ if (!proposedConfig) config.checks=inferChecks(repo.root);
51
+ const files = [{ path: '.elu-code/config.yaml', content: stringifyYaml(config), kind: 'file' }];
52
+ for (const [file, addition] of [['AGENTS.md', BLOCK], ['CLAUDE.md', IMPORT]]) {
53
+ const full = path.join(repo.root, file); noSymlinks(full);
54
+ if(file==='CLAUDE.md'&&!fs.existsSync(full))continue;
55
+ const before = fs.existsSync(full) ? fs.readFileSync(full, 'utf8') : '';
56
+ if (before.includes('<!-- elu-code:') || before.split(/\r?\n/).includes(IMPORT)) refuse(`${file} already contains a ELU Code block or import. Resolve it before init.`);
57
+ const suffix = `${before && !before.endsWith('\n') ? '\n' : ''}${before ? '\n' : ''}${addition}\n`;
58
+ files.push({ path: file, content: before + suffix, addition: suffix, created: !fs.existsSync(full), beforeHash: hash(before), kind: 'block' });
59
+ }
60
+ return { initialized: false, config, vendors: detect(repo.root).map(({ bin, ...value }) => value), files, writes: [...files.map((file) => path.join(repo.root, file.path)), `${repo.state}/`], message: config.checks.length?'Review the proposed checks before committing the configuration.':'No checks were found for this repository. Add checks to .elu-code/config.yaml before starting a task.' };
61
+ }
62
+ export function applyInit(repo, plan) {
63
+ if (plan.initialized) return plan;
64
+ if(plan.adopted&&hash(fs.readFileSync(path.join(repo.root,'.elu-code/config.yaml'),'utf8'))!==plan.adoptedHash)refuse('The committed configuration changed after the preview.');
65
+ for (const file of plan.files) {
66
+ const full = path.join(repo.root, file.path); noSymlinks(full);
67
+ if (file.kind === 'block') {
68
+ const current = fs.existsSync(full) ? fs.readFileSync(full, 'utf8') : '';
69
+ if (hash(current) !== file.beforeHash) refuse(`${file.path} changed since the write preview.`);
70
+ } else if (fs.existsSync(full)) refuse(`${file.path} appeared since the write preview.`);
71
+ }
72
+ mkdir(repo.state);
73
+ atomic(path.join(repo.state, 'manifest.json'), { version: 1, files: [...(plan.keepFiles ?? []), ...plan.files.map(({ content, ...file }) => ({ ...file, contentHash: hash(content) }))], worktrees: plan.worktrees ?? [] });
74
+ // Settings ELU Code proposed and the owner confirmed are approved by this save. Ones
75
+ // restored from the project's history are not: they are asked about like any other.
76
+ for (const file of plan.files) { if (file.path === '.elu-code/config.yaml' && !plan.fromHistory) approveSaved(repo.root, 'config.yaml', file.content); atomic(path.join(repo.root, file.path), file.content); }
77
+ event(repo, 'initialized');
78
+ return { initialized: true,adopted:Boolean(plan.adopted), writes: plan.writes, vendors: plan.vendors, message: plan.adopted?'Local state is ready. The committed files are unchanged.':`${plan.message} Commit the generated files after review.` };
79
+ }
80
+ export function uninstallPlan(repo) {
81
+ if(!fs.existsSync(path.join(repo.state,'manifest.json')) && !fs.existsSync(path.join(repo.state,'tasks')))return{installed:false,edits:[],worktrees:[],writes:[],message:'There is no local installation state to remove.'};
82
+ const manifest = fs.existsSync(path.join(repo.state, 'manifest.json')) ? readJson(path.join(repo.state, 'manifest.json')) : { version: 1, files: [] };
83
+ if (manifest.version !== 1 || !Array.isArray(manifest.files) || manifest.files.some((f) => !['.elu-code/config.yaml', 'AGENTS.md', 'CLAUDE.md'].includes(f.path))) refuse('Uninstall manifest is invalid.');
84
+ const active = tasks(repo).filter((task) => ['running', 'preparing'].includes(task.state));
85
+ if (active.length) refuse('Stop or recover running tasks before uninstalling.');
86
+ for (const file of fs.readdirSync(repo.state).filter((name) => /^task-[0-9]+\.lock$/.test(name) || name === 'view.lock')) {
87
+ const owner = readJson(path.join(repo.state, file));
88
+ if (processIdentity(owner.pid) === owner.identity) refuse('A project view or task is still running. Close it before uninstalling.');
89
+ }
90
+ for (const task of tasks(repo)) {
91
+ const file = path.join(repo.state, 'tasks', task.id, 'process.json');
92
+ if (!fs.existsSync(file)) continue;
93
+ const record = readJson(file);
94
+ if (record.pid && processIdentity(record.pid) === record.identity) refuse('A task process is still running. Use status --recover before uninstalling.');
95
+ }
96
+ // A session placed here edits the real checkout, which stays: it is not a worktree ELU Code made.
97
+ const worktrees = tasks(repo).filter(task => task.kind !== 'conversation' && checkPlace(task.place ?? null, { mode: 'full' }).kind !== 'here').map((task) => task.worktree).filter((file) => fs.existsSync(file));
98
+ if (worktrees.length) assertGitSafe(repo);
99
+ for (const folder of worktrees) {
100
+ noSymlinks(folder);
101
+ if (git(folder, ['status', '--porcelain']).stdout.trim()) refuse(`Worktree has uncommitted changes: ${folder}. Save them before uninstalling.`);
102
+ }
103
+ const edits = [];
104
+ for (const file of manifest.files) {
105
+ const full = path.join(repo.root, file.path); noSymlinks(full);
106
+ if (!fs.existsSync(full)) continue;
107
+ const current = fs.readFileSync(full, 'utf8');
108
+ if (file.kind === 'file') edits.push({ file: full, remove: true });
109
+ else {
110
+ if (!current.includes(file.addition) || current.indexOf(file.addition) !== current.lastIndexOf(file.addition)) refuse(`${file.path}'s marked addition changed. Remove it manually before uninstalling.`);
111
+ const content = current.replace(file.addition, '');
112
+ edits.push({ file: full, content, remove: file.created && !content });
113
+ }
114
+ }
115
+ const trackedDeletions=edits.filter(edit=>edit.remove&&git(repo.root,['ls-files','--error-unmatch','--',path.relative(repo.root,edit.file)],{allowFailure:true}).status===0).map(edit=>path.relative(repo.root,edit.file));
116
+ return { edits, worktrees,trackedDeletions, writes: [...edits.map((x) => x.file), ...worktrees, repo.state], message: `Removes generated files and local run history. Task branches and user edits outside marked additions are kept.${trackedDeletions.length?' Tracked files will be deleted; review and commit these removals.':''}` };
117
+ }
118
+ export function applyUninstall(repo, plan) {
119
+ if(plan.installed===false)return{uninstalled:true,trackedDeletions:[],message:plan.message};
120
+ // Removed from inside, so a worktree of another repository a session edited goes too.
121
+ for (const folder of plan.worktrees) git(folder, ['worktree', 'remove', folder]);
122
+ // Settings the owner removes here are gone by his word: none stays in force for the folder.
123
+ for (const edit of plan.edits) { if (!edit.remove) { atomic(edit.file, edit.content); continue; } if (path.relative(repo.root, edit.file) === path.join('.elu-code', 'config.yaml')) approveRemoved(repo.root, 'config.yaml'); fs.unlinkSync(edit.file); }
124
+ const configDir = path.join(repo.root, '.elu-code');
125
+ if (fs.existsSync(configDir) && fs.readdirSync(configDir).length === 0) fs.rmdirSync(configDir);
126
+ noSymlinks(repo.state); fs.rmSync(repo.state, { recursive: true });
127
+ if (fs.existsSync(repo.worktrees) && fs.readdirSync(repo.worktrees).length === 0) fs.rmdirSync(repo.worktrees);
128
+ return { uninstalled: true, keptBranches: true,trackedDeletions:plan.trackedDeletions,message:plan.message };
129
+ }