@elu-labs/elu 0.0.0-stage → 0.1.1
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 +340 -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 +270 -0
- package/lib/terminal.mjs +3181 -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/vendors.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Pure data about the vendor CLIs ELU Code can launch. No imports, so every module may read it.
|
|
2
|
+
export const VENDORS = Object.freeze(['claude', 'codex', 'agy', 'grok']);
|
|
3
|
+
export const VENDOR_NAMES = Object.freeze({ claude: 'Claude Code', codex: 'Codex', agy: 'Antigravity CLI', grok: 'Grok Build' });
|
|
4
|
+
// What the owner types in a terminal to sign in to each vendor's own CLI.
|
|
5
|
+
export const SIGN_IN_COMMAND = Object.freeze({ claude: 'claude', codex: 'codex login', agy: 'agy', grok: 'grok login' });
|
|
6
|
+
// lead/reviewer: roles it may take. lockedEdit: may edit in locked. commands: enforces a
|
|
7
|
+
// per-run command allow-list. budget: enforces a dollar budget. peer: may answer teammates.
|
|
8
|
+
// relay: how a live approval reaches the owner ('mcp' | 'acp' | null).
|
|
9
|
+
// conformance: needs the per-machine check before a run.
|
|
10
|
+
// model: 'none' | 'optional' | 'required' (a role may or must name a model).
|
|
11
|
+
// Codex leads in every mode through -c keys; its reviewer stays the locked launch.
|
|
12
|
+
export const CAPS = Object.freeze({
|
|
13
|
+
claude: Object.freeze({ lead: true, reviewer: true, lockedEdit: true, commands: true, budget: true, peer: true, relay: 'mcp', conformance: false, model: 'optional' }),
|
|
14
|
+
codex: Object.freeze({ lead: true, reviewer: true, lockedEdit: false, commands: false, budget: false, peer: false, relay: null, conformance: false, model: 'optional' }),
|
|
15
|
+
agy: Object.freeze({ lead: true, reviewer: true, lockedEdit: false, commands: false, budget: false, peer: false, relay: 'acp', conformance: true, model: 'optional' }),
|
|
16
|
+
grok: Object.freeze({ lead: true, reviewer: false, lockedEdit: false, commands: false, budget: false, peer: false, relay: 'acp', conformance: true, model: 'optional' }),
|
|
17
|
+
});
|
|
18
|
+
// The effort levels a role may ask for, and the ones each vendor's command line takes; an
|
|
19
|
+
// empty list means that vendor has no such setting. A model name becomes an argument, so it
|
|
20
|
+
// never starts with a dash.
|
|
21
|
+
export const EFFORTS = Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
22
|
+
export const VENDOR_EFFORTS = Object.freeze({ claude: EFFORTS, codex: EFFORTS, agy: Object.freeze([]), grok: Object.freeze([]) });
|
|
23
|
+
export const MODEL_NAME = /^[A-Za-z0-9][A-Za-z0-9._:/[\]-]{0,127}$/;
|
|
24
|
+
// The model of the helpers of a bot that names none for them: Claude Code's own word for
|
|
25
|
+
// the model of the turn that starts a helper.
|
|
26
|
+
export const OWN_MODEL = 'inherit';
|
|
27
|
+
// Home entries no agent may read, whatever the project is. Shared by the adapters and by
|
|
28
|
+
// the folder check, so one list says what a named folder may never be.
|
|
29
|
+
export const HOME_SECRETS = Object.freeze(['.ssh', '.aws', '.azure', '.config', '.claude', '.claude.json', '.codex', '.gnupg', '.docker', '.kube', '.netrc', '.git-credentials', '.pgpass', '.npmrc', '.pypirc', '.zsh_history', '.zsh_sessions', '.bash_history', '.python_history', '.node_repl_history', '.psql_history', '.mysql_history', 'Library']);
|
|
30
|
+
// ENFORCEMENT[vendor][profile] = [{ rule, enforcement }] for policyReport. An enforcement
|
|
31
|
+
// is a string, or a function of the role when it depends on the role's own settings.
|
|
32
|
+
const network = role => role.network === 'off' ? 'OS sandbox' : 'not expressible';
|
|
33
|
+
export const ENFORCEMENT = {
|
|
34
|
+
claude: {
|
|
35
|
+
locked: [
|
|
36
|
+
{ rule: 'network', enforcement: network },
|
|
37
|
+
{ rule: 'read and write paths', enforcement: role => `vendor permissions${role.commands.length ? ' and OS sandbox; temp folder writable' : ''}` },
|
|
38
|
+
{ rule: 'command prefixes', enforcement: role => role.commands.length ? 'vendor command permissions' : 'vendor tool list disables command execution' },
|
|
39
|
+
{ rule: 'no MCP servers or hooks', enforcement: 'vendor strict empty MCP and disabled hooks' },
|
|
40
|
+
{ rule: 'never push, tag, merge, deploy, or publish', enforcement: 'vendor tool restrictions and OS sandbox' },
|
|
41
|
+
{ rule: 'time limit', enforcement: 'process group timeout' },
|
|
42
|
+
{ rule: 'dollar limit', enforcement: 'vendor budget flag' },
|
|
43
|
+
],
|
|
44
|
+
// Ask for approval, Approve for me and Full access.
|
|
45
|
+
ask: [
|
|
46
|
+
{ rule: 'network', enforcement: 'vendor sandbox; web tools and escapes ask the owner' },
|
|
47
|
+
{ rule: 'read and write paths', enforcement: 'vendor permissions and OS sandbox; outside edits ask the owner' },
|
|
48
|
+
{ rule: 'command prefixes', enforcement: 'vendor sandbox; commands outside it ask the owner' },
|
|
49
|
+
{ rule: 'no MCP servers or hooks', enforcement: 'vendor strict MCP config (your servers and the approval relay) and disabled hooks' },
|
|
50
|
+
{ rule: 'never push, tag, merge, deploy, or publish', enforcement: 'vendor deny rules, stand-in commands first on PATH for what a script runs, and OS sandbox; every escape asks' },
|
|
51
|
+
{ rule: 'time limit', enforcement: 'process group timeout, paused while an approval waits' },
|
|
52
|
+
{ rule: 'dollar limit', enforcement: 'vendor budget flag' },
|
|
53
|
+
],
|
|
54
|
+
auto: [
|
|
55
|
+
{ rule: 'network', enforcement: 'vendor sandbox; a reviewer model decides escapes' },
|
|
56
|
+
{ rule: 'read and write paths', enforcement: 'vendor permissions and OS sandbox; a reviewer model decides outside edits' },
|
|
57
|
+
{ rule: 'command prefixes', enforcement: 'vendor sandbox; a reviewer model decides commands outside it' },
|
|
58
|
+
{ rule: 'no MCP servers or hooks', enforcement: 'vendor strict MCP config (your servers and the approval relay) and disabled hooks' },
|
|
59
|
+
{ rule: 'never push, tag, merge, deploy, or publish', enforcement: 'vendor deny rules and stand-in commands first on PATH; an obscured command gets through if the reviewer allows it' },
|
|
60
|
+
{ rule: 'time limit', enforcement: 'process group timeout, paused while an approval waits' },
|
|
61
|
+
{ rule: 'dollar limit', enforcement: 'vendor budget flag' },
|
|
62
|
+
],
|
|
63
|
+
full: [
|
|
64
|
+
{ rule: 'network', enforcement: 'not enforced' },
|
|
65
|
+
{ rule: 'read and write paths', enforcement: 'vendor deny rules for secrets and ELU Code state only; a shell can read anything' },
|
|
66
|
+
{ rule: 'command prefixes', enforcement: 'not enforced' },
|
|
67
|
+
{ rule: 'no MCP servers or hooks', enforcement: 'vendor strict MCP config (your servers and the approval relay) and disabled hooks' },
|
|
68
|
+
{ rule: 'never push, tag, merge, deploy, or publish', enforcement: 'prompt text, vendor deny rules and stand-in commands first on PATH only' },
|
|
69
|
+
{ rule: 'time limit', enforcement: 'process group timeout' },
|
|
70
|
+
{ rule: 'dollar limit', enforcement: 'vendor budget flag' },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
codex: {
|
|
74
|
+
locked: [
|
|
75
|
+
{ rule: 'network', enforcement: network },
|
|
76
|
+
{ rule: 'read and write paths', enforcement: 'OS sandbox' },
|
|
77
|
+
{ rule: 'command prefixes', enforcement: role => role.commands.length ? 'not expressible' : 'vendor tool list disables command execution' },
|
|
78
|
+
{ rule: 'no MCP servers or hooks', enforcement: 'ignore user configuration; project and managed configuration refuse launch' },
|
|
79
|
+
{ rule: 'never push, tag, merge, deploy, or publish', enforcement: 'vendor tool restrictions and OS sandbox' },
|
|
80
|
+
{ rule: 'time limit', enforcement: 'process group timeout' },
|
|
81
|
+
{ rule: 'dollar limit', enforcement: role => role.maxCostUsd === null ? 'not requested; time limit only' : 'not expressible' },
|
|
82
|
+
],
|
|
83
|
+
ask: [
|
|
84
|
+
{ rule: 'network', enforcement: 'OS sandbox; blocked actions are reported' },
|
|
85
|
+
{ rule: 'read and write paths', enforcement: 'vendor permission profile and OS sandbox; secrets and state denied, named folders read only' },
|
|
86
|
+
{ rule: 'command prefixes', enforcement: 'any command inside the OS sandbox' },
|
|
87
|
+
{ rule: 'no MCP servers or hooks', enforcement: 'ignore user configuration; project and managed configuration refuse launch' },
|
|
88
|
+
{ rule: 'never push, tag, merge, deploy, or publish', enforcement: 'stand-in commands first on PATH refuse by name; OS sandbox: no network, Git common directory read-only' },
|
|
89
|
+
{ rule: 'time limit', enforcement: 'process group timeout' },
|
|
90
|
+
{ rule: 'dollar limit', enforcement: role => role.maxCostUsd === null ? 'not requested; time limit only' : 'not expressible' },
|
|
91
|
+
],
|
|
92
|
+
auto: [
|
|
93
|
+
{ rule: 'network', enforcement: 'OS sandbox; escapes go to the vendor reviewer model' },
|
|
94
|
+
{ rule: 'read and write paths', enforcement: 'vendor permission profile and OS sandbox; escapes go to the vendor reviewer model' },
|
|
95
|
+
{ rule: 'command prefixes', enforcement: 'any command inside the OS sandbox; escapes reviewed' },
|
|
96
|
+
{ rule: 'no MCP servers or hooks', enforcement: 'ignore user configuration; project and managed configuration refuse launch' },
|
|
97
|
+
{ rule: 'never push, tag, merge, deploy, or publish', enforcement: 'stand-in commands first on PATH refuse by name (a tripwire, not a boundary); an escape goes to the vendor reviewer model' },
|
|
98
|
+
{ rule: 'time limit', enforcement: 'process group timeout' },
|
|
99
|
+
{ rule: 'dollar limit', enforcement: role => role.maxCostUsd === null ? 'not requested; time limit only' : 'not expressible' },
|
|
100
|
+
],
|
|
101
|
+
full: [
|
|
102
|
+
{ rule: 'network', enforcement: 'not enforced' },
|
|
103
|
+
{ rule: 'read and write paths', enforcement: 'not enforced; no sandbox' },
|
|
104
|
+
{ rule: 'command prefixes', enforcement: 'any command, no sandbox' },
|
|
105
|
+
{ rule: 'no MCP servers or hooks', enforcement: 'ignore user configuration; project and managed configuration refuse launch' },
|
|
106
|
+
{ rule: 'never push, tag, merge, deploy, or publish', enforcement: 'prompt text and stand-in commands first on PATH only; a full path to the real command gets past' },
|
|
107
|
+
{ rule: 'time limit', enforcement: 'process group timeout' },
|
|
108
|
+
{ rule: 'dollar limit', enforcement: role => role.maxCostUsd === null ? 'not requested; time limit only' : 'not expressible' },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
// Antigravity CLI runs under a private HOME holding only ELU Code's settings and hook; its
|
|
112
|
+
// enforcement is the vendor's permission rules, its sandbox, and the hook on command text.
|
|
113
|
+
agy: {
|
|
114
|
+
locked: [
|
|
115
|
+
{ rule: 'network', enforcement: 'vendor permissions deny read_url and execute_url; hook denies search_web' },
|
|
116
|
+
{ rule: 'read and write paths', enforcement: 'vendor workspace-only access and write_file deny; OS sandbox' },
|
|
117
|
+
{ rule: 'command prefixes', enforcement: role => role.commands.length ? 'not expressible' : 'vendor command deny and hook deny' },
|
|
118
|
+
{ rule: 'no MCP servers or hooks', enforcement: 'private HOME holds only ELU Code settings and its hook; vendor mcp deny' },
|
|
119
|
+
{ rule: 'never push, tag, merge, deploy, or publish', enforcement: 'vendor tool denies and OS sandbox' },
|
|
120
|
+
{ rule: 'time limit', enforcement: 'process group timeout and vendor print timeout' },
|
|
121
|
+
{ rule: 'dollar limit', enforcement: role => role.maxCostUsd === null ? 'not requested; time limit only' : 'not expressible' },
|
|
122
|
+
],
|
|
123
|
+
ask: [
|
|
124
|
+
{ rule: 'network', enforcement: 'asks you live over ACP, or denied and reported' },
|
|
125
|
+
{ rule: 'read and write paths', enforcement: 'workspace, and named folders read only; an outside edit asks or is reported; secret and protected paths denied' },
|
|
126
|
+
{ rule: 'command prefixes', enforcement: 'every command asks or is reported' },
|
|
127
|
+
{ rule: 'no MCP servers or hooks', enforcement: 'private HOME holds only ELU Code settings and its hook; MCP asks' },
|
|
128
|
+
{ rule: 'never push, tag, merge, deploy, or publish', enforcement: 'hook denies by command text, stand-in commands first on PATH refuse by name; commands ask' },
|
|
129
|
+
{ rule: 'time limit', enforcement: 'process group timeout and vendor print timeout; held while an approval waits' },
|
|
130
|
+
{ rule: 'dollar limit', enforcement: role => role.maxCostUsd === null ? 'not requested; time limit only' : 'not expressible' },
|
|
131
|
+
],
|
|
132
|
+
auto: [
|
|
133
|
+
{ rule: 'network', enforcement: 'reviewer model decides over ACP, or denied and reported; hook denies search_web' },
|
|
134
|
+
{ rule: 'read and write paths', enforcement: 'workspace, and named folders read only; OS sandbox; secret and protected paths denied' },
|
|
135
|
+
{ rule: 'command prefixes', enforcement: 'reviewer model decides or reported; sudo denied' },
|
|
136
|
+
{ rule: 'no MCP servers or hooks', enforcement: 'private HOME holds only ELU Code settings and its hook; MCP reviewed' },
|
|
137
|
+
{ rule: 'never push, tag, merge, deploy, or publish', enforcement: 'hook denies by command text, stand-in commands first on PATH refuse by name (tripwires, not a boundary)' },
|
|
138
|
+
{ rule: 'time limit', enforcement: 'process group timeout and vendor print timeout; held while an approval waits' },
|
|
139
|
+
{ rule: 'dollar limit', enforcement: role => role.maxCostUsd === null ? 'not requested; time limit only' : 'not expressible' },
|
|
140
|
+
],
|
|
141
|
+
full: [
|
|
142
|
+
{ rule: 'network', enforcement: 'open' },
|
|
143
|
+
{ rule: 'read and write paths', enforcement: 'any path; secret and state folders denied to the file tool only' },
|
|
144
|
+
{ rule: 'command prefixes', enforcement: 'any command, no sandbox' },
|
|
145
|
+
{ rule: 'no MCP servers or hooks', enforcement: 'private HOME holds only ELU Code settings and its hook; MCP allowed' },
|
|
146
|
+
{ rule: 'never push, tag, merge, deploy, or publish', enforcement: 'prompt text, the hook on command text and stand-in commands first on PATH only' },
|
|
147
|
+
{ rule: 'time limit', enforcement: 'process group timeout and vendor print timeout' },
|
|
148
|
+
{ rule: 'dollar limit', enforcement: role => role.maxCostUsd === null ? 'not requested; time limit only' : 'not expressible' },
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
// Grok Build: every profile is designed from the vendor's docs and proven
|
|
152
|
+
// per machine by Grok's one-time check. Deny rules match command text: a tripwire.
|
|
153
|
+
grok: {
|
|
154
|
+
locked: [
|
|
155
|
+
{ rule: 'network', enforcement: 'vendor tool list removes web tools; no shell; the model call itself uses the network' },
|
|
156
|
+
{ rule: 'read and write paths', enforcement: 'vendor read-only sandbox, tool list and deny rules; secrets denied by rule' },
|
|
157
|
+
{ rule: 'command prefixes', enforcement: 'vendor tool list disables command execution' },
|
|
158
|
+
{ rule: 'no MCP servers or hooks', enforcement: 'private home with ELU Code configuration; vendor Claude and Cursor compatibility off; no folder trust' },
|
|
159
|
+
{ rule: 'never push, tag, merge, deploy, or publish', enforcement: 'vendor tool restrictions and read-only sandbox' },
|
|
160
|
+
{ rule: 'time limit', enforcement: 'process group timeout and vendor turn limit' },
|
|
161
|
+
{ rule: 'dollar limit', enforcement: role => role.maxCostUsd === null ? 'not requested; time limit only' : 'not expressible' },
|
|
162
|
+
],
|
|
163
|
+
ask: [
|
|
164
|
+
{ rule: 'network', enforcement: 'vendor permission rules: web and shell ask over ACP, or are denied and reported in headless mode' },
|
|
165
|
+
{ rule: 'read and write paths', enforcement: 'vendor permission rules: workspace edits free, outside edits ask; secrets denied by rule' },
|
|
166
|
+
{ rule: 'command prefixes', enforcement: 'not expressible; every shell command asks' },
|
|
167
|
+
{ rule: 'no MCP servers or hooks', enforcement: 'private home with ELU Code configuration; vendor Claude and Cursor compatibility off; no folder trust' },
|
|
168
|
+
{ rule: 'never push, tag, merge, deploy, or publish', enforcement: 'vendor deny rules on command text and stand-in commands first on PATH (tripwires)' },
|
|
169
|
+
{ rule: 'time limit', enforcement: 'process group timeout and vendor turn limit; paused while an approval waits' },
|
|
170
|
+
{ rule: 'dollar limit', enforcement: role => role.maxCostUsd === null ? 'not requested; time limit only' : 'not expressible' },
|
|
171
|
+
],
|
|
172
|
+
auto: [
|
|
173
|
+
{ rule: 'network', enforcement: 'vendor reviewer model decides; a refused action asks over ACP or is reported' },
|
|
174
|
+
{ rule: 'read and write paths', enforcement: 'vendor permission rules and workspace sandbox in headless mode; secrets denied by rule' },
|
|
175
|
+
{ rule: 'command prefixes', enforcement: 'vendor reviewer model decides' },
|
|
176
|
+
{ rule: 'no MCP servers or hooks', enforcement: 'private home with ELU Code configuration; vendor Claude and Cursor compatibility off; no folder trust' },
|
|
177
|
+
{ rule: 'never push, tag, merge, deploy, or publish', enforcement: 'vendor deny rules on command text and stand-in commands first on PATH (tripwires)' },
|
|
178
|
+
{ rule: 'time limit', enforcement: 'process group timeout and vendor turn limit; paused while an approval waits' },
|
|
179
|
+
{ rule: 'dollar limit', enforcement: role => role.maxCostUsd === null ? 'not requested; time limit only' : 'not expressible' },
|
|
180
|
+
],
|
|
181
|
+
full: [
|
|
182
|
+
{ rule: 'network', enforcement: 'not enforced' },
|
|
183
|
+
{ rule: 'read and write paths', enforcement: 'not enforced; secret reads denied by rule (file tools only)' },
|
|
184
|
+
{ rule: 'command prefixes', enforcement: 'not enforced' },
|
|
185
|
+
{ rule: 'no MCP servers or hooks', enforcement: 'private home with ELU Code configuration; vendor Claude and Cursor compatibility off; no folder trust' },
|
|
186
|
+
{ rule: 'never push, tag, merge, deploy, or publish', enforcement: 'vendor deny rules on command text, stand-in commands first on PATH and prompt text only' },
|
|
187
|
+
{ rule: 'time limit', enforcement: 'process group timeout and vendor turn limit' },
|
|
188
|
+
{ rule: 'dollar limit', enforcement: role => role.maxCostUsd === null ? 'not requested; time limit only' : 'not expressible' },
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { findRepository, git, head, assertGitSafe, prepareGit } from './git.mjs';
|
|
6
|
+
import { atomic, hash, mkdir, noSymlinks, readJson, refuse, findBin } from './util.mjs';
|
|
7
|
+
import { personalDirectory } from './usage.mjs';
|
|
8
|
+
import { lock, waitLock } from './state.mjs';
|
|
9
|
+
|
|
10
|
+
export function projectPath(value, cwd = process.cwd()) {
|
|
11
|
+
if (typeof value !== 'string' || !value.trim() || /[\x00-\x1f\x7f]/.test(value)) refuse('Enter a project folder.');
|
|
12
|
+
return path.resolve(cwd, value === '~' ? os.homedir() : value.startsWith('~/') ? path.join(os.homedir(), value.slice(2)) : value);
|
|
13
|
+
}
|
|
14
|
+
export function projectFolder(value, cwd) {
|
|
15
|
+
const root = fs.realpathSync(projectPath(value, cwd));
|
|
16
|
+
if (!fs.statSync(root).isDirectory()) refuse('Choose a folder, not a file.');
|
|
17
|
+
const repo = findRepository(root); if (repo) return repo;
|
|
18
|
+
// A project is a name and its settings; the lead finds the code, so even your home works.
|
|
19
|
+
if (root === path.parse(root).root) refuse('Choose a folder, rather than your entire disk.');
|
|
20
|
+
return { root, common: null, state: path.join(personalDirectory(), 'projects', hash(root)), worktrees: path.join(path.dirname(root), `${path.basename(root)}.elu-code`) };
|
|
21
|
+
}
|
|
22
|
+
// Git repositories under your home, most recently used first, so the lead can pick one
|
|
23
|
+
// without searching. Bounded by depth, count and time; hidden folders, dependencies,
|
|
24
|
+
// system folders, linked worktrees and ELU Code's own editing workspaces are skipped.
|
|
25
|
+
const SKIP = new Set(['node_modules', 'Library', 'Applications', 'Pictures', 'Music', 'Movies', 'Public', 'vendor', 'dist', 'build', 'target']);
|
|
26
|
+
const used = dir => Math.max(0, ...['HEAD', 'index', 'FETCH_HEAD'].map(name => { try { return fs.statSync(path.join(dir, '.git', name)).mtimeMs; } catch { return 0; } }));
|
|
27
|
+
export function nearbyRepositories({ home = os.homedir(), depth = 4, limit = 60, scan = 2000, budgetMs = 1500, now = Date.now } = {}) {
|
|
28
|
+
const started = now(), found = [];
|
|
29
|
+
let level = [fs.realpathSync(home)];
|
|
30
|
+
for (let d = 0; d <= depth && level.length && found.length < scan && now() - started <= budgetMs; d++) {
|
|
31
|
+
const next = [];
|
|
32
|
+
for (const dir of level) {
|
|
33
|
+
if (found.length >= scan || now() - started > budgetMs) break;
|
|
34
|
+
let names; try { names = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
35
|
+
const git = names.find(entry => entry.name === '.git');
|
|
36
|
+
if (d > 0 && git) { if (git.isDirectory()) found.push(dir); continue; }
|
|
37
|
+
for (const entry of names) if (entry.isDirectory() && !entry.name.startsWith('.') && !SKIP.has(entry.name) && !entry.name.endsWith('.elu-code')) next.push(path.join(dir, entry.name));
|
|
38
|
+
}
|
|
39
|
+
level = next;
|
|
40
|
+
}
|
|
41
|
+
return found.map(dir => [used(dir), dir]).sort((a, b) => b[0] - a[0]).slice(0, limit).map(([, dir]) => dir);
|
|
42
|
+
}
|
|
43
|
+
export function createFolderPlan(value, cwd) {
|
|
44
|
+
const root = projectPath(value, cwd); noSymlinks(root);
|
|
45
|
+
if (fs.existsSync(root)) refuse('That folder already exists. Open it instead.');
|
|
46
|
+
let parent = path.dirname(root); while (!fs.existsSync(parent)) parent = path.dirname(parent);
|
|
47
|
+
if (findRepository(parent)) refuse('Choose a folder outside an existing project for a new project.');
|
|
48
|
+
return { root, writes: [root], message: 'Create this project folder? Git and project settings are added only when you start coding.' };
|
|
49
|
+
}
|
|
50
|
+
export function createFolder(plan) {
|
|
51
|
+
createFolderPlan(plan.root); fs.mkdirSync(plan.root, { recursive: true, mode: 0o700 }); return projectFolder(plan.root);
|
|
52
|
+
}
|
|
53
|
+
function initialFiles(root, repo) {
|
|
54
|
+
let scratch;
|
|
55
|
+
try {
|
|
56
|
+
let args = [];
|
|
57
|
+
if (!repo.common) {
|
|
58
|
+
mkdir(personalDirectory()); scratch = fs.mkdtempSync(path.join(personalDirectory(), 'history-'));
|
|
59
|
+
git(scratch, ['init', '--bare', '--template=', '--initial-branch=main']);
|
|
60
|
+
args = [`--git-dir=${scratch}`, `--work-tree=${root}`];
|
|
61
|
+
}
|
|
62
|
+
const files = git(root, [...args, '-c', 'core.fsmonitor=false', 'ls-files', '--cached', '--others', '--exclude-standard', '-z']).stdout.split('\0').filter(Boolean);
|
|
63
|
+
return [...new Set(files)].sort().map(file => {
|
|
64
|
+
if (/(^|\/)(?:\.env(?:\..*)?|\.npmrc|\.netrc|auth\.json|credentials\.json|id_rsa|id_ed25519)$|\.(?:pem|p12|key)$/i.test(file)) refuse(`Keep ${file} out of history using .gitignore before starting.`);
|
|
65
|
+
const full = path.join(root, file); noSymlinks(full);
|
|
66
|
+
const stat = fs.statSync(full); if (!stat.isFile() || stat.size > 4 * 1024 * 1024) refuse(`Review this file before creating project history: ${file}`);
|
|
67
|
+
return { path: file, hash: hash(fs.readFileSync(full)), mode: stat.mode & 0o777 };
|
|
68
|
+
});
|
|
69
|
+
} finally { if (scratch) fs.rmSync(scratch, { recursive: true, force: true }); }
|
|
70
|
+
}
|
|
71
|
+
export function historyPlan(location) {
|
|
72
|
+
if (!findBin('git')) refuse('Install Git before starting a coding task. You can still open ELU Code.');
|
|
73
|
+
const repo = projectFolder(location.root);
|
|
74
|
+
if (repo.root !== location.root) refuse('This folder is now part of another project. Open the project again.');
|
|
75
|
+
if (repo.common && git(repo.root, ['rev-parse', '--verify', 'HEAD'], { allowFailure: true }).status === 0) return null;
|
|
76
|
+
const files = initialFiles(repo.root, repo);
|
|
77
|
+
return { root: repo.root, create: !repo.common, files, digest: hash(JSON.stringify(files)), message: 'Create local project history and an initial checkpoint of these files. Nothing is pushed and no remote repository is created.' };
|
|
78
|
+
}
|
|
79
|
+
export function createHistory(approved) {
|
|
80
|
+
const current = historyPlan({ root: approved.root });
|
|
81
|
+
if (!current || current.create !== approved.create || current.digest !== approved.digest) refuse('The project changed after the history preview. Review it again.');
|
|
82
|
+
if (current.create) git(current.root, ['init', '--template=', '--initial-branch=main']);
|
|
83
|
+
const repo = projectFolder(current.root); assertGitSafe(repo);
|
|
84
|
+
const release = lock(repo);
|
|
85
|
+
try {
|
|
86
|
+
const hooks = path.join(repo.state, 'empty-hooks'); mkdir(hooks);
|
|
87
|
+
if (fs.readdirSync(hooks).length) refuse('The Git preparation hook directory is not empty.');
|
|
88
|
+
if (current.files.length) prepareGit(repo, repo.root, ['add', '--', ...current.files.map(file => file.path)]);
|
|
89
|
+
if (hash(JSON.stringify(initialFiles(repo.root, repo))) !== current.digest) refuse('The project changed while preparing its checkpoint. Review it again.');
|
|
90
|
+
git(repo.root, ['-c', `core.hooksPath=${hooks}`, 'commit', '--allow-empty', '-m', 'Start project history']);
|
|
91
|
+
head(repo.root); return repo;
|
|
92
|
+
} finally { release(); }
|
|
93
|
+
}
|
|
94
|
+
const requestFile = root => path.join(personalDirectory(), 'requests', `${hash(root)}.json`);
|
|
95
|
+
export function pendingRequests(root) {
|
|
96
|
+
const file = requestFile(root); noSymlinks(file);
|
|
97
|
+
if (!fs.existsSync(file)) return [];
|
|
98
|
+
const rows = readJson(file);
|
|
99
|
+
if (!Array.isArray(rows) || rows.some(row => typeof row?.id !== 'string' || !/^request-[a-f0-9-]+$/.test(row.id) || typeof row.goal !== 'string')) refuse('Saved requests could not be read.');
|
|
100
|
+
return rows.filter(row => row.state !== 'cancelled');
|
|
101
|
+
}
|
|
102
|
+
const ownRequest = ({ origin, readOnly, context, ...row }) => row;
|
|
103
|
+
export function saveRequest(root, goal, id, update = {}) {
|
|
104
|
+
if (typeof goal !== 'string' || !goal.trim() || goal.length > 16000) refuse('Enter a request of 1 to 16000 characters.');
|
|
105
|
+
const release = waitLock({ state: personalDirectory() }, `requests-${hash(root)}`);
|
|
106
|
+
try {
|
|
107
|
+
const rows = pendingRequests(root), found = id && rows.find(row => row.id === id);
|
|
108
|
+
// An owner who rewrites a teammate's request makes it their own, so its peer fields go.
|
|
109
|
+
const existing = found && found.goal !== goal ? ownRequest(found) : found;
|
|
110
|
+
const row = { ...(existing ?? { id: `request-${crypto.randomUUID()}`, createdAt: new Date().toISOString() }), goal, state: 'waiting', reason: 'Ready to review and start.', ...update };
|
|
111
|
+
atomic(requestFile(root), [...rows.filter(value => value.id !== row.id), row]); return row;
|
|
112
|
+
} finally { release(); }
|
|
113
|
+
}
|
|
114
|
+
// Adds a message to a request that has not started: its text goes after the request's own,
|
|
115
|
+
// a blank line apart, while the request is still queued and the whole stays one message's
|
|
116
|
+
// length. One step, so the conductor never starts the request with half of it. Returns the
|
|
117
|
+
// request, or null when it is no longer queued or would grow too long.
|
|
118
|
+
export function addToRequest(root, id, text) {
|
|
119
|
+
const release = waitLock({ state: personalDirectory() }, `requests-${hash(root)}`);
|
|
120
|
+
try {
|
|
121
|
+
const rows = pendingRequests(root), found = rows.find(row => row.id === id && row.state === 'queued');
|
|
122
|
+
if (!found || found.goal.length + text.length + 2 > 16000) return null;
|
|
123
|
+
const row = { ...found, goal: `${found.goal}\n\n${text}` };
|
|
124
|
+
atomic(requestFile(root), rows.map(value => value.id === id ? row : value)); return row;
|
|
125
|
+
} finally { release(); }
|
|
126
|
+
}
|
|
127
|
+
export function removeRequest(root, id) {
|
|
128
|
+
const release = waitLock({ state: personalDirectory() }, `requests-${hash(root)}`);
|
|
129
|
+
try { atomic(requestFile(root), pendingRequests(root).filter(row => row.id !== id)); } finally { release(); }
|
|
130
|
+
}
|
|
131
|
+
// Takes back a message that still waits: it is removed only while it is queued, in one
|
|
132
|
+
// step, so a message the conductor has begun to start is never reported as taken back.
|
|
133
|
+
// Returns the removed request, or null when it is gone or already starting.
|
|
134
|
+
export function takeRequest(root, id) {
|
|
135
|
+
const release = waitLock({ state: personalDirectory() }, `requests-${hash(root)}`);
|
|
136
|
+
try {
|
|
137
|
+
const rows = pendingRequests(root), found = rows.find(row => row.id === id && row.state === 'queued');
|
|
138
|
+
if (found) atomic(requestFile(root), rows.filter(row => row.id !== id));
|
|
139
|
+
return found ?? null;
|
|
140
|
+
} finally { release(); }
|
|
141
|
+
}
|
|
142
|
+
// Joins waiting messages into the first of them, in the order given and a blank line
|
|
143
|
+
// apart, while each is still queued and the whole stays one message's length. One step, so
|
|
144
|
+
// a message taken back a moment ago is not sent. Returns the joined request, or null when
|
|
145
|
+
// the first is no longer queued.
|
|
146
|
+
export function joinRequests(root, ids) {
|
|
147
|
+
const release = waitLock({ state: personalDirectory() }, `requests-${hash(root)}`);
|
|
148
|
+
try {
|
|
149
|
+
const rows = pendingRequests(root), found = ids.map(id => rows.find(row => row.id === id && row.state === 'queued')).filter(Boolean);
|
|
150
|
+
if (found[0]?.id !== ids[0]) return null;
|
|
151
|
+
const joined = [found[0]]; let goal = found[0].goal;
|
|
152
|
+
for (const row of found.slice(1)) { if (goal.length + row.goal.length + 2 > 16000) break; goal += `\n\n${row.goal}`; joined.push(row); }
|
|
153
|
+
if (joined.length === 1) return found[0];
|
|
154
|
+
const gone = new Set(joined.slice(1).map(row => row.id)), row = { ...found[0], goal };
|
|
155
|
+
atomic(requestFile(root), rows.filter(value => !gone.has(value.id)).map(value => value.id === row.id ? row : value)); return row;
|
|
156
|
+
} finally { release(); }
|
|
157
|
+
}
|
|
158
|
+
// Changes a request only while it still exists in an expected state, so a request
|
|
159
|
+
// cancelled in one terminal cannot be revived by the conductor, or the reverse.
|
|
160
|
+
export function updateRequest(root, id, update, { from } = {}) {
|
|
161
|
+
const release = waitLock({ state: personalDirectory() }, `requests-${hash(root)}`);
|
|
162
|
+
try {
|
|
163
|
+
const rows = pendingRequests(root), existing = rows.find(row => row.id === id);
|
|
164
|
+
if (!existing || from && !from.includes(existing.state)) return null;
|
|
165
|
+
const row = { ...existing, ...update };
|
|
166
|
+
atomic(requestFile(root), rows.map(value => value.id === id ? row : value)); return row;
|
|
167
|
+
} finally { release(); }
|
|
168
|
+
}
|