@illuminis/comprism 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 +15 -0
- package/README.md +281 -0
- package/out/agent/command.d.ts +86 -0
- package/out/agent/command.js +259 -0
- package/out/agent/render.d.ts +97 -0
- package/out/agent/render.js +255 -0
- package/out/agent/session.d.ts +175 -0
- package/out/agent/session.js +573 -0
- package/out/commands/ask.d.ts +1 -0
- package/out/commands/ask.js +146 -0
- package/out/commands/codemap.d.ts +2 -0
- package/out/commands/codemap.js +151 -0
- package/out/commands/commands-thin.d.ts +39 -0
- package/out/commands/commands-thin.js +182 -0
- package/out/commands/install.d.ts +163 -0
- package/out/commands/install.js +543 -0
- package/out/commands/keys.d.ts +55 -0
- package/out/commands/keys.js +344 -0
- package/out/commands/login.d.ts +9 -0
- package/out/commands/login.js +384 -0
- package/out/commands/repl.d.ts +1 -0
- package/out/commands/repl.js +752 -0
- package/out/commands/settings.d.ts +21 -0
- package/out/commands/settings.js +244 -0
- package/out/commands/welcome.d.ts +1 -0
- package/out/commands/welcome.js +196 -0
- package/out/executor/documents.d.ts +40 -0
- package/out/executor/documents.js +170 -0
- package/out/executor/files.d.ts +2 -0
- package/out/executor/files.js +360 -0
- package/out/executor/git.d.ts +48 -0
- package/out/executor/git.js +132 -0
- package/out/executor/hooks.d.ts +67 -0
- package/out/executor/hooks.js +247 -0
- package/out/executor/index.d.ts +29 -0
- package/out/executor/index.js +221 -0
- package/out/executor/notebook.d.ts +2 -0
- package/out/executor/notebook.js +147 -0
- package/out/executor/paths.d.ts +15 -0
- package/out/executor/paths.js +126 -0
- package/out/executor/shell.d.ts +41 -0
- package/out/executor/shell.js +336 -0
- package/out/graph/build.d.ts +45 -0
- package/out/graph/build.js +91 -0
- package/out/graph/facts.d.ts +47 -0
- package/out/graph/facts.js +12 -0
- package/out/graph/files.d.ts +45 -0
- package/out/graph/files.js +207 -0
- package/out/graph/read-locales.d.ts +29 -0
- package/out/graph/read-locales.js +246 -0
- package/out/graph/read-python.d.ts +11 -0
- package/out/graph/read-python.js +115 -0
- package/out/graph/read-typescript.d.ts +16 -0
- package/out/graph/read-typescript.js +292 -0
- package/out/graph/sync.d.ts +66 -0
- package/out/graph/sync.js +242 -0
- package/out/lib/attach.d.ts +62 -0
- package/out/lib/attach.js +228 -0
- package/out/lib/config.d.ts +93 -0
- package/out/lib/config.js +198 -0
- package/out/lib/connection.d.ts +73 -0
- package/out/lib/connection.js +188 -0
- package/out/lib/gateway.d.ts +239 -0
- package/out/lib/gateway.js +171 -0
- package/out/lib/prompt.d.ts +34 -0
- package/out/lib/prompt.js +108 -0
- package/out/lib/types.d.ts +417 -0
- package/out/lib/types.js +21 -0
- package/out/lib/ui.d.ts +114 -0
- package/out/lib/ui.js +265 -0
- package/out/lib/version.d.ts +24 -0
- package/out/lib/version.js +27 -0
- package/out/lib/voice.d.ts +50 -0
- package/out/lib/voice.js +218 -0
- package/out/postinstall.d.ts +2 -0
- package/out/postinstall.js +92 -0
- package/out/thin.d.ts +2 -0
- package/out/thin.js +259 -0
- package/package.json +101 -0
- package/scripts/read_python.py +270 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.performFileAction = performFileAction;
|
|
37
|
+
/**
|
|
38
|
+
* The file actions, on a real filesystem.
|
|
39
|
+
*
|
|
40
|
+
* Specification: docs/modules/CODING_AGENT_BUILD_SPECIFICATION.md, register
|
|
41
|
+
* items D1 to D16 and E6 to E8.
|
|
42
|
+
*
|
|
43
|
+
* The same twelve actions the browser performs, with the same refusals, against
|
|
44
|
+
* a real disk. **The refusals are the part worth reading**: an ambiguous edit,
|
|
45
|
+
* a path outside the project, a credentials file. An executor that reads and
|
|
46
|
+
* writes is easy; one that refuses correctly is the one somebody can leave
|
|
47
|
+
* running.
|
|
48
|
+
*/
|
|
49
|
+
const fs = __importStar(require("fs"));
|
|
50
|
+
const path = __importStar(require("path"));
|
|
51
|
+
const paths_1 = require("./paths");
|
|
52
|
+
const MAX_FILE_CHARS = 60_000;
|
|
53
|
+
const MAX_RESULTS = 200;
|
|
54
|
+
const MAX_MAP_PATHS = 2_000;
|
|
55
|
+
const SKIP_DIRS = new Set([
|
|
56
|
+
"node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", ".venv",
|
|
57
|
+
"venv", "env", "__pycache__", ".pytest_cache", ".mypy_cache", "coverage",
|
|
58
|
+
".turbo", ".cache", "target", "vendor", ".idea", "Pods", ".gradle",
|
|
59
|
+
".terraform", ".serverless", "site-packages",
|
|
60
|
+
]);
|
|
61
|
+
const TEXT_EXT = new Set([
|
|
62
|
+
"ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "rb", "go", "rs", "java", "kt",
|
|
63
|
+
"swift", "c", "h", "cc", "cpp", "hpp", "cs", "php", "sh", "bash", "zsh",
|
|
64
|
+
"sql", "html", "css", "scss", "less", "json", "jsonc", "yaml", "yml", "toml",
|
|
65
|
+
"ini", "cfg", "conf", "md", "mdx", "txt", "rst", "csv", "tsv", "xml", "svg",
|
|
66
|
+
"vue", "svelte", "astro", "graphql", "gql", "proto", "lock", "prisma", "tf",
|
|
67
|
+
"r", "jl", "lua", "pl", "ex", "exs",
|
|
68
|
+
]);
|
|
69
|
+
function isText(name) {
|
|
70
|
+
const lower = name.toLowerCase();
|
|
71
|
+
if (!lower.includes("."))
|
|
72
|
+
return ["makefile", "dockerfile", "gitignore"].includes(lower);
|
|
73
|
+
return TEXT_EXT.has(lower.split(".").pop());
|
|
74
|
+
}
|
|
75
|
+
function ok(content, summary) {
|
|
76
|
+
return { content, summary: summary ?? content.split("\n")[0].slice(0, 200) };
|
|
77
|
+
}
|
|
78
|
+
function fail(content) {
|
|
79
|
+
return { content, isError: true, summary: content.slice(0, 200) };
|
|
80
|
+
}
|
|
81
|
+
function walk(dir, root, out, limit) {
|
|
82
|
+
if (out.length >= limit)
|
|
83
|
+
return;
|
|
84
|
+
let entries;
|
|
85
|
+
try {
|
|
86
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
for (const e of entries) {
|
|
92
|
+
if (out.length >= limit)
|
|
93
|
+
return;
|
|
94
|
+
const full = path.join(dir, e.name);
|
|
95
|
+
if (e.isDirectory()) {
|
|
96
|
+
if (SKIP_DIRS.has(e.name))
|
|
97
|
+
continue;
|
|
98
|
+
// Not followed. A link pointing outside the project would otherwise pull
|
|
99
|
+
// the whole machine into a file map, and `resolveInside` would never see
|
|
100
|
+
// it because nobody asked for that path by name.
|
|
101
|
+
if (e.isSymbolicLink())
|
|
102
|
+
continue;
|
|
103
|
+
walk(full, root, out, limit);
|
|
104
|
+
}
|
|
105
|
+
else if (e.isFile()) {
|
|
106
|
+
out.push(path.relative(root, full));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function globToRegExp(pattern) {
|
|
111
|
+
let out = "";
|
|
112
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
113
|
+
const ch = pattern[i];
|
|
114
|
+
if (ch === "*") {
|
|
115
|
+
if (pattern[i + 1] === "*") {
|
|
116
|
+
out += ".*";
|
|
117
|
+
i += 1;
|
|
118
|
+
if (pattern[i + 1] === "/")
|
|
119
|
+
i += 1;
|
|
120
|
+
}
|
|
121
|
+
else
|
|
122
|
+
out += "[^/]*";
|
|
123
|
+
}
|
|
124
|
+
else if (ch === "?")
|
|
125
|
+
out += "[^/]";
|
|
126
|
+
else
|
|
127
|
+
out += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
128
|
+
}
|
|
129
|
+
return new RegExp(`^${out}$`);
|
|
130
|
+
}
|
|
131
|
+
function countOf(haystack, needle) {
|
|
132
|
+
if (!needle)
|
|
133
|
+
return 0;
|
|
134
|
+
let n = 0, at = haystack.indexOf(needle);
|
|
135
|
+
while (at !== -1) {
|
|
136
|
+
n += 1;
|
|
137
|
+
at = haystack.indexOf(needle, at + needle.length);
|
|
138
|
+
}
|
|
139
|
+
return n;
|
|
140
|
+
}
|
|
141
|
+
function performFileAction(root, name, a) {
|
|
142
|
+
const realRoot = fs.realpathSync(root);
|
|
143
|
+
try {
|
|
144
|
+
switch (name) {
|
|
145
|
+
case "read_file": {
|
|
146
|
+
const rel = String(a.path ?? "");
|
|
147
|
+
if ((0, paths_1.isSecret)(rel)) {
|
|
148
|
+
return fail(`Refused: ${rel} holds credentials, and credentials are never read into a prompt.`);
|
|
149
|
+
}
|
|
150
|
+
const p = (0, paths_1.resolveInside)(realRoot, rel, true);
|
|
151
|
+
if (!isText(path.basename(p))) {
|
|
152
|
+
const size = fs.statSync(p).size;
|
|
153
|
+
return fail(`${rel} is not a text file (${size.toLocaleString()} bytes). Reading it would be noise rather than context.`);
|
|
154
|
+
}
|
|
155
|
+
let body = fs.readFileSync(p, "utf8");
|
|
156
|
+
const offset = Number(a.offset ?? 0), limit = Number(a.limit ?? 0);
|
|
157
|
+
if (offset > 0 || limit > 0) {
|
|
158
|
+
const lines = body.split("\n");
|
|
159
|
+
const from = Math.max(0, offset > 0 ? offset - 1 : 0);
|
|
160
|
+
body = lines.slice(from, limit > 0 ? from + limit : undefined).join("\n");
|
|
161
|
+
}
|
|
162
|
+
if (body.length > MAX_FILE_CHARS) {
|
|
163
|
+
body = `${body.slice(0, MAX_FILE_CHARS)}\n\n[... truncated. Use offset and limit to read further.]`;
|
|
164
|
+
}
|
|
165
|
+
return ok(body, `read ${rel}`);
|
|
166
|
+
}
|
|
167
|
+
case "list_dir": {
|
|
168
|
+
const p = (0, paths_1.resolveInside)(realRoot, String(a.path ?? ""), true);
|
|
169
|
+
const rows = fs.readdirSync(p, { withFileTypes: true })
|
|
170
|
+
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name)).sort();
|
|
171
|
+
return ok(rows.join("\n") || "(empty)", `${rows.length} entries`);
|
|
172
|
+
}
|
|
173
|
+
case "file_map": {
|
|
174
|
+
const p = (0, paths_1.resolveInside)(realRoot, String(a.path ?? ""), true);
|
|
175
|
+
const found = [];
|
|
176
|
+
walk(p, realRoot, found, MAX_MAP_PATHS);
|
|
177
|
+
found.sort();
|
|
178
|
+
const note = found.length >= MAX_MAP_PATHS
|
|
179
|
+
? `\n\n[... stopped at ${MAX_MAP_PATHS} paths. Map a subfolder for the rest.]` : "";
|
|
180
|
+
return ok(found.join("\n") + note, `${found.length} files`);
|
|
181
|
+
}
|
|
182
|
+
case "glob": {
|
|
183
|
+
const p = (0, paths_1.resolveInside)(realRoot, String(a.path ?? ""), true);
|
|
184
|
+
const found = [];
|
|
185
|
+
walk(p, realRoot, found, MAX_MAP_PATHS);
|
|
186
|
+
const rx = globToRegExp(String(a.pattern ?? ""));
|
|
187
|
+
const hits = found.filter((f) => rx.test(f)).slice(0, MAX_RESULTS);
|
|
188
|
+
return ok(hits.join("\n") || "(no matches)", `${hits.length} files`);
|
|
189
|
+
}
|
|
190
|
+
case "grep": {
|
|
191
|
+
const p = (0, paths_1.resolveInside)(realRoot, String(a.path ?? ""), true);
|
|
192
|
+
let rx;
|
|
193
|
+
try {
|
|
194
|
+
rx = new RegExp(String(a.pattern ?? ""), a.case_sensitive ? "" : "i");
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
return fail(`That is not a valid regular expression: ${e.message}`);
|
|
198
|
+
}
|
|
199
|
+
const globRx = a.glob ? globToRegExp(String(a.glob)) : null;
|
|
200
|
+
const files = [];
|
|
201
|
+
walk(p, realRoot, files, MAX_MAP_PATHS);
|
|
202
|
+
const context = Math.max(0, Math.min(Number(a.context_lines ?? 0), 5));
|
|
203
|
+
const hits = [];
|
|
204
|
+
for (const rel of files) {
|
|
205
|
+
if (hits.length >= MAX_RESULTS)
|
|
206
|
+
break;
|
|
207
|
+
const leaf = path.basename(rel);
|
|
208
|
+
if (!isText(leaf) || (0, paths_1.isSecret)(leaf))
|
|
209
|
+
continue;
|
|
210
|
+
if (globRx && !globRx.test(rel))
|
|
211
|
+
continue;
|
|
212
|
+
let text;
|
|
213
|
+
try {
|
|
214
|
+
text = fs.readFileSync(path.join(realRoot, rel), "utf8");
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const lines = text.split("\n");
|
|
220
|
+
for (let i = 0; i < lines.length && hits.length < MAX_RESULTS; i += 1) {
|
|
221
|
+
if (!rx.test(lines[i]))
|
|
222
|
+
continue;
|
|
223
|
+
if (context === 0)
|
|
224
|
+
hits.push(`${rel}:${i + 1}:${lines[i]}`);
|
|
225
|
+
else {
|
|
226
|
+
const from = Math.max(0, i - context), to = Math.min(lines.length, i + context + 1);
|
|
227
|
+
hits.push(`${rel}:${i + 1}:\n${lines.slice(from, to).join("\n")}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const note = hits.length >= MAX_RESULTS ? `\n\n[... stopped at ${MAX_RESULTS} matches. Narrow the search.]` : "";
|
|
232
|
+
return ok(hits.join("\n") + note || "(no matches)", `${hits.length} matches`);
|
|
233
|
+
}
|
|
234
|
+
// Bytes rather than text, and the ONLY caller is the server writing a
|
|
235
|
+
// document it just built. A Word file, a deck or a PDF is not text, so it
|
|
236
|
+
// cannot come down the ordinary write, and a person who asked for a deck
|
|
237
|
+
// wants it in the folder they are standing in rather than behind a link.
|
|
238
|
+
//
|
|
239
|
+
// Deliberately NOT offered to the model. It is issued by the loop after
|
|
240
|
+
// `make_document`, in the same way the project's instruction file is read
|
|
241
|
+
// without the model asking. A model that could write arbitrary bytes into
|
|
242
|
+
// a repository would be a way to put something in a file that no diff
|
|
243
|
+
// shows and nobody reviews.
|
|
244
|
+
case "write_bytes": {
|
|
245
|
+
const rel = String(a.path ?? "");
|
|
246
|
+
if (a.base64 === undefined || a.base64 === null) {
|
|
247
|
+
return fail(`Refused, and nothing was written: write_bytes was called for ${rel} with no content.`);
|
|
248
|
+
}
|
|
249
|
+
const p = (0, paths_1.resolveInside)(realRoot, rel, false);
|
|
250
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
251
|
+
const replaced = fs.existsSync(p);
|
|
252
|
+
const bytes = Buffer.from(String(a.base64), "base64");
|
|
253
|
+
fs.writeFileSync(p, bytes);
|
|
254
|
+
return ok(`${replaced ? "Replaced" : "Saved"} ${rel} (${bytes.length.toLocaleString()} bytes).`, `${replaced ? "replaced" : "saved"} ${rel}`);
|
|
255
|
+
}
|
|
256
|
+
case "write_file": {
|
|
257
|
+
const rel = String(a.path ?? "");
|
|
258
|
+
// A call that arrives WITHOUT content is a malformed call, not a request
|
|
259
|
+
// for an empty file. Treating the two as the same is how a whole study
|
|
260
|
+
// was lost on 11 September 2026: three of four builds saved a blank page,
|
|
261
|
+
// this returned "ok", and nobody found out until the files were measured.
|
|
262
|
+
// The model was not at fault - it was told the write succeeded, and on one
|
|
263
|
+
// run it tried three times and was told so three times.
|
|
264
|
+
//
|
|
265
|
+
// An empty file is still reachable, deliberately, with content: "".
|
|
266
|
+
if (a.content === undefined || a.content === null) {
|
|
267
|
+
return fail(`Refused, and nothing was written: write_file was called for ${rel} with no content at all. Send the file's full text in "content". To create a genuinely empty file, pass an empty string.`);
|
|
268
|
+
}
|
|
269
|
+
const p = (0, paths_1.resolveInside)(realRoot, rel, false);
|
|
270
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
271
|
+
const content = String(a.content);
|
|
272
|
+
// Whether this REPLACED something is checked before the write, because
|
|
273
|
+
// afterwards there is nothing left to tell from. Writing no longer stops
|
|
274
|
+
// to ask, so saying which of the two happened is the only way a person
|
|
275
|
+
// watching knows that a file they already had was just replaced. "wrote"
|
|
276
|
+
// reads identically in both cases and hides exactly the one that matters.
|
|
277
|
+
const replaced = fs.existsSync(p);
|
|
278
|
+
fs.writeFileSync(p, content, "utf8");
|
|
279
|
+
const size = `${content.length.toLocaleString()} characters`;
|
|
280
|
+
return replaced
|
|
281
|
+
? ok(`Rewrote ${rel}, replacing what was there (${size}).`, `rewrote ${rel}`)
|
|
282
|
+
: ok(`Created ${rel} (${size}).`, `created ${rel}`);
|
|
283
|
+
}
|
|
284
|
+
case "edit_file": {
|
|
285
|
+
const rel = String(a.path ?? "");
|
|
286
|
+
const p = (0, paths_1.resolveInside)(realRoot, rel, true);
|
|
287
|
+
const text = fs.readFileSync(p, "utf8");
|
|
288
|
+
const oldText = String(a.old_text ?? "");
|
|
289
|
+
const n = countOf(text, oldText);
|
|
290
|
+
if (n === 0) {
|
|
291
|
+
return fail(`Refused: that exact text is not in ${rel}. Read the file again — whitespace and indentation have to match exactly.`);
|
|
292
|
+
}
|
|
293
|
+
if (n > 1) {
|
|
294
|
+
return fail(`Refused: that text appears ${n} times in ${rel}, so it is not clear which one you mean. Include more surrounding lines to make the target unique.`);
|
|
295
|
+
}
|
|
296
|
+
// Missing replacement text used to DELETE the matched section and report
|
|
297
|
+
// "edited". Worse than the write case, because it destroys work that was
|
|
298
|
+
// already there. Deleting a section on purpose still works, with
|
|
299
|
+
// new_text set to an empty string.
|
|
300
|
+
if (a.new_text === undefined || a.new_text === null) {
|
|
301
|
+
return fail(`Refused, and nothing was changed: edit_file was called for ${rel} with no replacement text. Send "new_text". To delete the matched section, pass an empty string.`);
|
|
302
|
+
}
|
|
303
|
+
fs.writeFileSync(p, text.replace(oldText, String(a.new_text)), "utf8");
|
|
304
|
+
return ok(`Edited ${rel}.`, `edited ${rel}`);
|
|
305
|
+
}
|
|
306
|
+
case "multi_edit": {
|
|
307
|
+
const rel = String(a.path ?? "");
|
|
308
|
+
const p = (0, paths_1.resolveInside)(realRoot, rel, true);
|
|
309
|
+
const edits = a.edits ?? [];
|
|
310
|
+
let next = fs.readFileSync(p, "utf8");
|
|
311
|
+
for (let i = 0; i < edits.length; i += 1) {
|
|
312
|
+
const from = String(edits[i].old_text ?? "");
|
|
313
|
+
const n = countOf(next, from);
|
|
314
|
+
if (n !== 1) {
|
|
315
|
+
return fail(`Refused, and nothing was changed: edit ${i + 1} of ${edits.length} matches ${n} times in ${rel}. Every edit has to match exactly once.`);
|
|
316
|
+
}
|
|
317
|
+
// Same rule as edit_file, checked before anything is written, so a bad
|
|
318
|
+
// edit halfway down a list cannot leave the file half changed.
|
|
319
|
+
const to = edits[i].new_text;
|
|
320
|
+
if (to === undefined || to === null) {
|
|
321
|
+
return fail(`Refused, and nothing was changed: edit ${i + 1} of ${edits.length} for ${rel} has no replacement text. Send "new_text". To delete the matched section, pass an empty string.`);
|
|
322
|
+
}
|
|
323
|
+
next = next.replace(from, String(to));
|
|
324
|
+
}
|
|
325
|
+
fs.writeFileSync(p, next, "utf8");
|
|
326
|
+
return ok(`Applied ${edits.length} edits to ${rel}.`, `${edits.length} edits`);
|
|
327
|
+
}
|
|
328
|
+
case "create_dir": {
|
|
329
|
+
const rel = String(a.path ?? "");
|
|
330
|
+
fs.mkdirSync((0, paths_1.resolveInside)(realRoot, rel, false), { recursive: true });
|
|
331
|
+
return ok(`Created ${rel}.`, `created ${rel}`);
|
|
332
|
+
}
|
|
333
|
+
case "delete_file": {
|
|
334
|
+
const rel = String(a.path ?? "");
|
|
335
|
+
fs.unlinkSync((0, paths_1.resolveInside)(realRoot, rel, true));
|
|
336
|
+
return ok(`Deleted ${rel}.`, `deleted ${rel}`);
|
|
337
|
+
}
|
|
338
|
+
case "move_file": {
|
|
339
|
+
const rel = String(a.path ?? ""), to = String(a.to ?? "");
|
|
340
|
+
const from = (0, paths_1.resolveInside)(realRoot, rel, true);
|
|
341
|
+
const dest = (0, paths_1.resolveInside)(realRoot, to, false);
|
|
342
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
343
|
+
fs.renameSync(from, dest);
|
|
344
|
+
return ok(`Moved ${rel} to ${to}.`, `moved ${rel}`);
|
|
345
|
+
}
|
|
346
|
+
default:
|
|
347
|
+
return fail(`This client cannot do that: ${name}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
if (err instanceof paths_1.OutsideWorkspace)
|
|
352
|
+
return fail(err.message);
|
|
353
|
+
const e = err;
|
|
354
|
+
if (e.code === "ENOENT")
|
|
355
|
+
return fail(`No such file: ${a.path ?? ""}`);
|
|
356
|
+
if (e.code === "EACCES")
|
|
357
|
+
return fail(`Permission denied: ${a.path ?? ""}`);
|
|
358
|
+
return fail(`This action could not be completed: ${e.message}`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the repository, and recording work in it.
|
|
3
|
+
*
|
|
4
|
+
* Specification: docs/modules/CODING_AGENT_BUILD_SPECIFICATION.md, register
|
|
5
|
+
* items D23 to D27.
|
|
6
|
+
*
|
|
7
|
+
* **Pushing is the only thing here that sends anything anywhere**, and it is
|
|
8
|
+
* the only one the server has to have allowed first. Pull requests, reviews,
|
|
9
|
+
* checks and releases are not git operations at all: they go through GitHub's
|
|
10
|
+
* own API, on the server, because they need the tenant's credential and that
|
|
11
|
+
* must never be sent to a client.
|
|
12
|
+
*
|
|
13
|
+
* So the split is by what each thing needs. A push needs the repository, which
|
|
14
|
+
* is here. A pull request needs the credential, which is there.
|
|
15
|
+
*
|
|
16
|
+
* Every command goes through `runCommand`, so the folder boundary, the trimmed
|
|
17
|
+
* environment, the timeout and the output cap all apply here too rather than
|
|
18
|
+
* being reimplemented for git.
|
|
19
|
+
*/
|
|
20
|
+
import { RunResult, ShellOptions } from "./shell";
|
|
21
|
+
export declare function gitStatus(opts: ShellOptions): Promise<RunResult>;
|
|
22
|
+
export declare function gitDiff(opts: ShellOptions & {
|
|
23
|
+
path?: string;
|
|
24
|
+
staged?: boolean;
|
|
25
|
+
}): Promise<RunResult>;
|
|
26
|
+
export declare function gitLog(opts: ShellOptions & {
|
|
27
|
+
path?: string;
|
|
28
|
+
limit?: number;
|
|
29
|
+
}): Promise<RunResult>;
|
|
30
|
+
export declare function gitBranch(name: string, opts: ShellOptions): Promise<RunResult>;
|
|
31
|
+
export declare function gitCommit(message: string, opts: ShellOptions & {
|
|
32
|
+
paths?: string[];
|
|
33
|
+
}): Promise<RunResult>;
|
|
34
|
+
/** Send commits to the remote.
|
|
35
|
+
*
|
|
36
|
+
* Reached only after the server has allowed it: the branch is one the person
|
|
37
|
+
* named for this repository, it is not a branch the project is built from, and
|
|
38
|
+
* they have just confirmed it. This function does not re-decide any of that,
|
|
39
|
+
* and it must not: a client that could decide whether it may publish is a
|
|
40
|
+
* client that could grant itself permission.
|
|
41
|
+
*
|
|
42
|
+
* **There is no force push and there is no upstream rewrite.** Not as an
|
|
43
|
+
* option, not behind a flag. Everything on the remote stays recoverable by
|
|
44
|
+
* whoever is working from it.
|
|
45
|
+
*/
|
|
46
|
+
export declare function gitPush(branch: string, opts: ShellOptions & {
|
|
47
|
+
createRemote?: boolean;
|
|
48
|
+
}): Promise<RunResult>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.gitStatus = gitStatus;
|
|
4
|
+
exports.gitDiff = gitDiff;
|
|
5
|
+
exports.gitLog = gitLog;
|
|
6
|
+
exports.gitBranch = gitBranch;
|
|
7
|
+
exports.gitCommit = gitCommit;
|
|
8
|
+
exports.gitPush = gitPush;
|
|
9
|
+
/**
|
|
10
|
+
* Reading the repository, and recording work in it.
|
|
11
|
+
*
|
|
12
|
+
* Specification: docs/modules/CODING_AGENT_BUILD_SPECIFICATION.md, register
|
|
13
|
+
* items D23 to D27.
|
|
14
|
+
*
|
|
15
|
+
* **Pushing is the only thing here that sends anything anywhere**, and it is
|
|
16
|
+
* the only one the server has to have allowed first. Pull requests, reviews,
|
|
17
|
+
* checks and releases are not git operations at all: they go through GitHub's
|
|
18
|
+
* own API, on the server, because they need the tenant's credential and that
|
|
19
|
+
* must never be sent to a client.
|
|
20
|
+
*
|
|
21
|
+
* So the split is by what each thing needs. A push needs the repository, which
|
|
22
|
+
* is here. A pull request needs the credential, which is there.
|
|
23
|
+
*
|
|
24
|
+
* Every command goes through `runCommand`, so the folder boundary, the trimmed
|
|
25
|
+
* environment, the timeout and the output cap all apply here too rather than
|
|
26
|
+
* being reimplemented for git.
|
|
27
|
+
*/
|
|
28
|
+
const shell_1 = require("./shell");
|
|
29
|
+
/** Arguments passed as a list and quoted, never interpolated into a string.
|
|
30
|
+
*
|
|
31
|
+
* A commit message is written by a model and can contain anything: quotes,
|
|
32
|
+
* backticks, a newline, a semicolon. Pasting one into a shell string is how a
|
|
33
|
+
* message becomes a second command. */
|
|
34
|
+
function quote(value) {
|
|
35
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
36
|
+
}
|
|
37
|
+
async function gitStatus(opts) {
|
|
38
|
+
// Porcelain, because the human-readable format changes between versions and
|
|
39
|
+
// the model has to be able to rely on the shape.
|
|
40
|
+
const r = await (0, shell_1.runCommand)("git status --porcelain=v1 --branch", opts);
|
|
41
|
+
if (!r.isError && /^exit 0/.test(r.content) && r.content.trim().split("\n").length <= 3) {
|
|
42
|
+
return { ...r, content: `${r.content}\n\n[Nothing has changed in the working tree.]` };
|
|
43
|
+
}
|
|
44
|
+
return r;
|
|
45
|
+
}
|
|
46
|
+
async function gitDiff(opts) {
|
|
47
|
+
const parts = ["git", "--no-pager", "diff"];
|
|
48
|
+
if (opts.staged)
|
|
49
|
+
parts.push("--staged");
|
|
50
|
+
// Color off explicitly: a diff full of escape codes costs tokens and reads
|
|
51
|
+
// as noise to a model.
|
|
52
|
+
parts.push("--no-color");
|
|
53
|
+
if (opts.path)
|
|
54
|
+
parts.push("--", quote(opts.path));
|
|
55
|
+
return (0, shell_1.runCommand)(parts.join(" "), opts);
|
|
56
|
+
}
|
|
57
|
+
async function gitLog(opts) {
|
|
58
|
+
const limit = Math.max(1, Math.min(opts.limit ?? 20, 100));
|
|
59
|
+
const parts = [
|
|
60
|
+
"git", "--no-pager", "log", `-n ${limit}`,
|
|
61
|
+
// Quoted. Everything here goes through a shell, so an unquoted format string
|
|
62
|
+
// is split on its spaces and git reads "%an" as a revision. It fails with
|
|
63
|
+
// "ambiguous argument", which reads like a broken repository rather than a
|
|
64
|
+
// quoting mistake, and cost a test run to diagnose.
|
|
65
|
+
`--pretty=${quote("format:%h %an %ar %s")}`,
|
|
66
|
+
];
|
|
67
|
+
if (opts.path)
|
|
68
|
+
parts.push("--", quote(opts.path));
|
|
69
|
+
return (0, shell_1.runCommand)(parts.join(" "), opts);
|
|
70
|
+
}
|
|
71
|
+
async function gitBranch(name, opts) {
|
|
72
|
+
if (!/^[\w./-]{1,120}$/.test(name)) {
|
|
73
|
+
return {
|
|
74
|
+
isError: true,
|
|
75
|
+
content: `Refused: ${name} is not a usable branch name. Letters, numbers, dots, dashes, underscores and slashes only.`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return (0, shell_1.runCommand)(`git checkout -b ${quote(name)}`, opts);
|
|
79
|
+
}
|
|
80
|
+
async function gitCommit(message, opts) {
|
|
81
|
+
if (!message.trim()) {
|
|
82
|
+
return { isError: true, content: "Refused: a commit needs a message." };
|
|
83
|
+
}
|
|
84
|
+
const stage = opts.paths?.length
|
|
85
|
+
? `git add -- ${opts.paths.map(quote).join(" ")}`
|
|
86
|
+
: "git add -A";
|
|
87
|
+
const staged = await (0, shell_1.runCommand)(stage, opts);
|
|
88
|
+
if (staged.isError)
|
|
89
|
+
return staged;
|
|
90
|
+
// Nothing to commit is a normal outcome, not a failure. Reported plainly so
|
|
91
|
+
// the model does not conclude that committing is broken and try again.
|
|
92
|
+
const check = await (0, shell_1.runCommand)("git diff --staged --quiet", opts);
|
|
93
|
+
if (/^exit 0\b/.test(check.content)) {
|
|
94
|
+
return { content: "Nothing to commit: no changes are staged.", summary: "nothing to commit" };
|
|
95
|
+
}
|
|
96
|
+
// Attribution, always. A commit that hides having been written by an agent is
|
|
97
|
+
// a commit somebody later reads as a colleague's careful work, and the whole
|
|
98
|
+
// record this product keeps depends on that not happening.
|
|
99
|
+
const trailer = "\n\nCo-authored-by: illuminis coding agent <agent@illuminis.ai>";
|
|
100
|
+
return (0, shell_1.runCommand)(`git commit -m ${quote(message + trailer)}`, opts);
|
|
101
|
+
}
|
|
102
|
+
/** Send commits to the remote.
|
|
103
|
+
*
|
|
104
|
+
* Reached only after the server has allowed it: the branch is one the person
|
|
105
|
+
* named for this repository, it is not a branch the project is built from, and
|
|
106
|
+
* they have just confirmed it. This function does not re-decide any of that,
|
|
107
|
+
* and it must not: a client that could decide whether it may publish is a
|
|
108
|
+
* client that could grant itself permission.
|
|
109
|
+
*
|
|
110
|
+
* **There is no force push and there is no upstream rewrite.** Not as an
|
|
111
|
+
* option, not behind a flag. Everything on the remote stays recoverable by
|
|
112
|
+
* whoever is working from it.
|
|
113
|
+
*/
|
|
114
|
+
async function gitPush(branch, opts) {
|
|
115
|
+
if (!/^[\w./-]{1,120}$/.test(branch)) {
|
|
116
|
+
return { isError: true, content: `Refused: ${branch} is not a usable branch name.` };
|
|
117
|
+
}
|
|
118
|
+
// What is about to go, named before it goes. A push that reports success
|
|
119
|
+
// without saying what it sent is a push nobody can check afterwards.
|
|
120
|
+
const ahead = await (0, shell_1.runCommand)(`git --no-pager log --oneline origin/${quote(branch)}..HEAD 2>/dev/null || git --no-pager log --oneline -5`, opts);
|
|
121
|
+
// `-u` on a new branch so later pushes need no arguments, and nothing else.
|
|
122
|
+
// No `--force`, no `--force-with-lease`, no `+refs` refspec.
|
|
123
|
+
const flags = opts.createRemote ? "-u" : "";
|
|
124
|
+
const pushed = await (0, shell_1.runCommand)(`git push ${flags} origin ${quote(branch)}`.replace(/\s+/g, " "), opts);
|
|
125
|
+
if (pushed.isError || !/^exit 0/.test(pushed.content)) {
|
|
126
|
+
return { ...pushed, isError: true };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
content: `Pushed to ${branch}.\n\nWhat went:\n${ahead.content}`,
|
|
130
|
+
summary: `pushed ${branch}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export interface Hook {
|
|
2
|
+
/** Which actions this runs around. Three forms, all common in the market:
|
|
3
|
+
*
|
|
4
|
+
* "edit_file" exactly that one
|
|
5
|
+
* "write_file|edit_file" any in the list
|
|
6
|
+
* "*" every one
|
|
7
|
+
*
|
|
8
|
+
* Anything else is treated as a regular expression anchored at both ends, so
|
|
9
|
+
* `"git_.*"` covers the git actions without naming all five. A pattern that
|
|
10
|
+
* will not compile matches nothing and says so once, rather than matching
|
|
11
|
+
* everything: a broken pattern that ran a hook on every action would be the
|
|
12
|
+
* dangerous way to be wrong. */
|
|
13
|
+
on: string;
|
|
14
|
+
/** "before" or "after". */
|
|
15
|
+
when: "before" | "after";
|
|
16
|
+
command: string;
|
|
17
|
+
/** Words a person reads in the timeline when it runs. */
|
|
18
|
+
what?: string;
|
|
19
|
+
}
|
|
20
|
+
/** What this project asks for, or nothing.
|
|
21
|
+
*
|
|
22
|
+
* A malformed file is ignored with a warning rather than failing the job. A
|
|
23
|
+
* project whose hook configuration has a trailing comma should not be a project
|
|
24
|
+
* where the agent refuses to work, and the person who broke it is not usually
|
|
25
|
+
* the person now trying to get something done.
|
|
26
|
+
*/
|
|
27
|
+
export declare function hooksFor(root: string): Hook[];
|
|
28
|
+
/** Does this hook's matcher cover this action.
|
|
29
|
+
*
|
|
30
|
+
* Exported because it is the part with the rules in it, and a rule with no
|
|
31
|
+
* test is a rule that drifts.
|
|
32
|
+
*/
|
|
33
|
+
export declare function covers(pattern: string, action: string): boolean;
|
|
34
|
+
export interface HookOutcome {
|
|
35
|
+
ran: number;
|
|
36
|
+
/** Set when a `before` hook refused the action. */
|
|
37
|
+
refusedBy?: string;
|
|
38
|
+
detail?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Run the hooks that come before one action.
|
|
42
|
+
*
|
|
43
|
+
* **A non-zero exit cancels the action.** This is the one place a hook changes
|
|
44
|
+
* what happens, and it is deliberate: "do not let the agent touch anything under
|
|
45
|
+
* generated/" is a real thing to want, and refusing is the only way for a
|
|
46
|
+
* project to say it.
|
|
47
|
+
*
|
|
48
|
+
* It can only ever REFUSE. There is no exit code that approves something the
|
|
49
|
+
* agent was not already allowed to do, because a permission system inside the
|
|
50
|
+
* repository would live exactly where an attacker who got that far already is.
|
|
51
|
+
*/
|
|
52
|
+
export declare function before(root: string, action: string, args: Record<string, unknown>, hooks?: Hook[]): Promise<HookOutcome>;
|
|
53
|
+
/**
|
|
54
|
+
* Run the hooks that come after one action.
|
|
55
|
+
*
|
|
56
|
+
* A failure here is reported and nothing else: the action has already happened,
|
|
57
|
+
* and refusing it afterwards is not a thing that can be done. The overwhelming
|
|
58
|
+
* case is a formatter, and a formatter that fails is worth knowing about and not
|
|
59
|
+
* worth losing the work over.
|
|
60
|
+
*/
|
|
61
|
+
export declare function after(root: string, action: string, args: Record<string, unknown>, hooks?: Hook[]): Promise<{
|
|
62
|
+
ran: number;
|
|
63
|
+
failed: string[];
|
|
64
|
+
}>;
|
|
65
|
+
/** The example a person copies. Kept in code so the readme and the behavior
|
|
66
|
+
* cannot drift: this is what the reader actually gets. */
|
|
67
|
+
export declare const EXAMPLE: string;
|