@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.
Files changed (80) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +281 -0
  3. package/out/agent/command.d.ts +86 -0
  4. package/out/agent/command.js +259 -0
  5. package/out/agent/render.d.ts +97 -0
  6. package/out/agent/render.js +255 -0
  7. package/out/agent/session.d.ts +175 -0
  8. package/out/agent/session.js +573 -0
  9. package/out/commands/ask.d.ts +1 -0
  10. package/out/commands/ask.js +146 -0
  11. package/out/commands/codemap.d.ts +2 -0
  12. package/out/commands/codemap.js +151 -0
  13. package/out/commands/commands-thin.d.ts +39 -0
  14. package/out/commands/commands-thin.js +182 -0
  15. package/out/commands/install.d.ts +163 -0
  16. package/out/commands/install.js +543 -0
  17. package/out/commands/keys.d.ts +55 -0
  18. package/out/commands/keys.js +344 -0
  19. package/out/commands/login.d.ts +9 -0
  20. package/out/commands/login.js +384 -0
  21. package/out/commands/repl.d.ts +1 -0
  22. package/out/commands/repl.js +752 -0
  23. package/out/commands/settings.d.ts +21 -0
  24. package/out/commands/settings.js +244 -0
  25. package/out/commands/welcome.d.ts +1 -0
  26. package/out/commands/welcome.js +196 -0
  27. package/out/executor/documents.d.ts +40 -0
  28. package/out/executor/documents.js +170 -0
  29. package/out/executor/files.d.ts +2 -0
  30. package/out/executor/files.js +360 -0
  31. package/out/executor/git.d.ts +48 -0
  32. package/out/executor/git.js +132 -0
  33. package/out/executor/hooks.d.ts +67 -0
  34. package/out/executor/hooks.js +247 -0
  35. package/out/executor/index.d.ts +29 -0
  36. package/out/executor/index.js +221 -0
  37. package/out/executor/notebook.d.ts +2 -0
  38. package/out/executor/notebook.js +147 -0
  39. package/out/executor/paths.d.ts +15 -0
  40. package/out/executor/paths.js +126 -0
  41. package/out/executor/shell.d.ts +41 -0
  42. package/out/executor/shell.js +336 -0
  43. package/out/graph/build.d.ts +45 -0
  44. package/out/graph/build.js +91 -0
  45. package/out/graph/facts.d.ts +47 -0
  46. package/out/graph/facts.js +12 -0
  47. package/out/graph/files.d.ts +45 -0
  48. package/out/graph/files.js +207 -0
  49. package/out/graph/read-locales.d.ts +29 -0
  50. package/out/graph/read-locales.js +246 -0
  51. package/out/graph/read-python.d.ts +11 -0
  52. package/out/graph/read-python.js +115 -0
  53. package/out/graph/read-typescript.d.ts +16 -0
  54. package/out/graph/read-typescript.js +292 -0
  55. package/out/graph/sync.d.ts +66 -0
  56. package/out/graph/sync.js +242 -0
  57. package/out/lib/attach.d.ts +62 -0
  58. package/out/lib/attach.js +228 -0
  59. package/out/lib/config.d.ts +93 -0
  60. package/out/lib/config.js +198 -0
  61. package/out/lib/connection.d.ts +73 -0
  62. package/out/lib/connection.js +188 -0
  63. package/out/lib/gateway.d.ts +239 -0
  64. package/out/lib/gateway.js +171 -0
  65. package/out/lib/prompt.d.ts +34 -0
  66. package/out/lib/prompt.js +108 -0
  67. package/out/lib/types.d.ts +417 -0
  68. package/out/lib/types.js +21 -0
  69. package/out/lib/ui.d.ts +114 -0
  70. package/out/lib/ui.js +265 -0
  71. package/out/lib/version.d.ts +24 -0
  72. package/out/lib/version.js +27 -0
  73. package/out/lib/voice.d.ts +50 -0
  74. package/out/lib/voice.js +218 -0
  75. package/out/postinstall.d.ts +2 -0
  76. package/out/postinstall.js +92 -0
  77. package/out/thin.d.ts +2 -0
  78. package/out/thin.js +259 -0
  79. package/package.json +101 -0
  80. 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;