premanmcp 1.1.7 → 1.1.9

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.
@@ -0,0 +1,762 @@
1
+ /**
2
+ * Find the code that runs this repository's agent, and make it callable.
3
+ *
4
+ * An eval is about somebody's agent. Until now the CLI found that agent by
5
+ * sweeping localhost for an HTTP service, which could only ever match PreMan's
6
+ * own backend -- so in a customer's repository it measured the wrong process
7
+ * and reported the score as if it were theirs. The fix is not a better sweep.
8
+ * It is to read the repository and call the agent directly, which is both more
9
+ * accurate and impossible to confuse with a neighbouring service.
10
+ *
11
+ * The output is a small Python file, `.preman/preman_agent_entry.py`, wrapping
12
+ * whatever shape the real entry point has in the one signature assert-ai calls.
13
+ * A model writes it; a person approves it. That review is the only real control
14
+ * here, and the code says so where it matters -- everything else (refusing to
15
+ * write outside `.preman/`, compiling it, calling it once) is hygiene that
16
+ * catches mistakes, not a sandbox that contains a hostile file.
17
+ *
18
+ * Nothing in this module runs unattended. A daemon or a CI job that reaches
19
+ * discovery without an approved entry point is told to run `preman eval target`
20
+ * rather than having one written for it.
21
+ */
22
+
23
+ import { spawnSync } from "node:child_process";
24
+ import { createHash } from "node:crypto";
25
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
26
+ import path from "node:path";
27
+ import { createInterface } from "node:readline";
28
+
29
+ import { callBackendJson, cliInvocation, resolveApiKey } from "./shared.js";
30
+ import { REPO_CONFIG, findConfigRoot, findRepoRoot, saveEntry } from "./target.js";
31
+ import { TargetError, verifyEntry } from "./eval_target.js";
32
+ import { SHAPES, TemplateError, buildFreeform, buildWrapper } from "./eval_templates.js";
33
+
34
+ /** The one importable name a device eval may use. The backend pins it too. */
35
+ export const ENTRY_MODULE = "preman_agent_entry";
36
+ export const ENTRY_SYMBOL = "chat";
37
+ export const ENTRY_REF = `${ENTRY_MODULE}:${ENTRY_SYMBOL}`;
38
+
39
+ /**
40
+ * `.preman`, not `.preman.py`, and the module is the bare name.
41
+ *
42
+ * A leading dot makes `importlib` read the name as a relative import, so the
43
+ * directory can never be a dotted module segment. Putting the directory on the
44
+ * import path instead and importing the bare name is what makes a hidden
45
+ * directory usable at all; the `preman_` prefix is what stops the bare name
46
+ * colliding with something already on the customer's path.
47
+ */
48
+ export const ENTRY_DIR = ".preman";
49
+ export const ENTRY_FILE = `${ENTRY_MODULE}.py`;
50
+
51
+ /** The node half of a `node_bridge` target, beside the wrapper that spawns it. */
52
+ export const BRIDGE_FILE = "agent_bridge.mjs";
53
+
54
+ const SEARCH_TIMEOUT_MS = 120_000;
55
+ const PROBE_TIMEOUT_MS = 60_000;
56
+ const MAX_TREE_PATHS = 4_000;
57
+ const MAX_TREE_BYTES = 120_000;
58
+ const MAX_SHORTLIST = 12;
59
+ const MAX_HEAD_LINES = 400;
60
+ const MAX_HEAD_BYTES = 16_000;
61
+ const MIN_CONFIDENCE = 0.6;
62
+
63
+ const SKIP_DIRS = new Set([
64
+ "node_modules", ".venv", "venv", ".git", "dist", "build", "__pycache__",
65
+ ".next", ".turbo", "target", ".mypy_cache", ".pytest_cache", ".preman",
66
+ ]);
67
+
68
+ /** Filenames that tend to hold an agent's entry point. */
69
+ const NAME_HINTS = /(agent|chat|graph|assistant|bot|server|main|app|workflow|crew)/i;
70
+
71
+ /**
72
+ * Paths that cannot be an entry point however well they score.
73
+ *
74
+ * Measured, not guessed: against a large Rust repository the twelve shortlist
75
+ * slots filled with `app-server/tests/suite/v2/*.rs` and generated TypeScript
76
+ * schema definitions, because `NAME_HINTS` matches "app", "server" and "Agent"
77
+ * in a filename and says nothing about whether a file is code somebody calls. A
78
+ * test for an agent and a generated type describing one both look exactly like
79
+ * an agent to a filename.
80
+ */
81
+ const NOT_ENTRY = [
82
+ /(^|\/)(tests?|__tests__|spec|e2e|fixtures?|examples?|benches|testdata)(\/|$)/i,
83
+ /(^|\/)[^/]*[._-](test|spec|bench)\.[^/]+$/i,
84
+ /(^|\/)(schema|schemas|generated|gen|proto|migrations)(\/)/i,
85
+ /\.d\.ts$/i,
86
+ ];
87
+
88
+ /** Imports that say "this file is where an agent is built". */
89
+ const FRAMEWORK_MARKERS = [
90
+ "StateGraph", "langgraph", "langchain", "AgentExecutor", "crewai", "autogen",
91
+ "llama_index", "litellm", "anthropic", "openai", "@ai-sdk", "mastra",
92
+ "pydantic_ai", "semantic_kernel", "smolagents",
93
+ ];
94
+
95
+ // Python and JS/TS are imported or bridged; the rest are reachable only as a
96
+ // `cli_subprocess`, and are listed so the model can see an agent written in them
97
+ // at all. An extension here is not a promise that a shape exists for it -- the
98
+ // shape table is the other half, and adding one without the other shows the
99
+ // model code it has no way to invoke.
100
+ const SOURCE_EXT = new Set([
101
+ ".py", ".ts", ".tsx", ".js", ".mjs", ".cjs",
102
+ ".rs", ".go", ".java", ".kt", ".rb", ".cs", ".ex", ".exs", ".php", ".swift",
103
+ ]);
104
+
105
+ // Manifests that declare a runnable command. For a compiled agent the entry
106
+ // point is not a source file at all -- it is a binary named in one of these,
107
+ // which is a fact rather than an inference.
108
+ const MANIFESTS = [
109
+ "Cargo.toml", "go.mod", "pom.xml", "build.gradle", "build.gradle.kts",
110
+ "Gemfile", "mix.exs", "composer.json", "Package.swift", "Makefile",
111
+ ];
112
+
113
+ /**
114
+ * The credential this search runs on, or a refusal naming the fixes.
115
+ *
116
+ * Asked before the repository is walked. Without it the walk is wasted work
117
+ * ending in a 401 that names no remedy -- which is exactly what it did.
118
+ */
119
+ function requireKey(args) {
120
+ const key = resolveApiKey(args);
121
+ if (key) return key;
122
+ throw new TargetError(
123
+ `no PreMan API key on this machine, so there is no account to read this ` +
124
+ `repository on behalf of. Run \`${cliInvocation()} login\`, or set ` +
125
+ `PREMAN_API_KEY. You can also point at a running agent with --target <url>.`
126
+ );
127
+ }
128
+
129
+ // ── Reading the repository ──────────────────────────────────────────────
130
+
131
+ /**
132
+ * Every source path worth showing, cheapest way first.
133
+ *
134
+ * `git ls-files` is preferred because it already honours `.gitignore`, which is
135
+ * the repository's own statement about what is not its code. The walk is the
136
+ * fallback for a directory that is not a checkout, and it has to reproduce the
137
+ * exclusions by hand.
138
+ */
139
+ export function repoTree(root) {
140
+ // `--others --exclude-standard` alongside `--cached`, because a plain
141
+ // `ls-files` lists only *tracked* files -- so a fresh checkout, a scratch
142
+ // repository, or simply an agent added since the last commit would read as
143
+ // "there is no code here". `--exclude-standard` keeps `.gitignore` honoured,
144
+ // which is the reason to prefer git over a hand-rolled walk at all.
145
+ const tracked = spawnSync(
146
+ "git",
147
+ ["-C", root, "ls-files", "--cached", "--others", "--exclude-standard"],
148
+ { encoding: "utf8" }
149
+ );
150
+ let paths;
151
+ if (tracked.status === 0) {
152
+ paths = tracked.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
153
+ } else {
154
+ paths = [];
155
+ const walk = (dir, depth) => {
156
+ if (depth > 8 || paths.length > MAX_TREE_PATHS) return;
157
+ let entries = [];
158
+ try {
159
+ entries = readdirSync(dir, { withFileTypes: true });
160
+ } catch {
161
+ return;
162
+ }
163
+ for (const entry of entries) {
164
+ if (entry.name.startsWith(".") && entry.name !== ".preman") continue;
165
+ const full = path.join(dir, entry.name);
166
+ if (entry.isDirectory()) {
167
+ if (!SKIP_DIRS.has(entry.name)) walk(full, depth + 1);
168
+ } else {
169
+ paths.push(path.relative(root, full).split(path.sep).join("/"));
170
+ }
171
+ }
172
+ };
173
+ walk(root, 0);
174
+ }
175
+
176
+ const kept = [];
177
+ let bytes = 0;
178
+ for (const rel of paths) {
179
+ if (rel.split("/").some((segment) => SKIP_DIRS.has(segment))) continue;
180
+ if (bytes + rel.length > MAX_TREE_BYTES || kept.length >= MAX_TREE_PATHS) break;
181
+ kept.push(rel);
182
+ bytes += rel.length + 1;
183
+ }
184
+ return kept;
185
+ }
186
+
187
+ /** The files most likely to hold the entry point, with their opening lines. */
188
+ export function shortlist(root, tree) {
189
+ const scored = [];
190
+ for (const rel of tree) {
191
+ if (!SOURCE_EXT.has(path.extname(rel))) continue;
192
+ if (NOT_ENTRY.some((pattern) => pattern.test(rel))) continue;
193
+ const full = path.join(root, rel);
194
+ let head = "";
195
+ try {
196
+ if (statSync(full).size > 1_000_000) continue;
197
+ head = readFileSync(full, "utf8");
198
+ } catch {
199
+ continue;
200
+ }
201
+
202
+ let score = 0;
203
+ if (NAME_HINTS.test(path.basename(rel))) score += 3;
204
+ // A file at the top of the tree is likelier to be an entry point than one
205
+ // buried in a package, so shallower paths break ties upward.
206
+ score += Math.max(0, 3 - rel.split("/").length);
207
+ for (const marker of FRAMEWORK_MARKERS) if (head.includes(marker)) score += 2;
208
+ if (score <= 0) continue;
209
+
210
+ const lines = head.split("\n").slice(0, MAX_HEAD_LINES).join("\n");
211
+ scored.push({ path: rel, score, head: lines.slice(0, MAX_HEAD_BYTES) });
212
+ }
213
+ const best = scored.sort((a, b) => b.score - a.score).slice(0, MAX_SHORTLIST);
214
+
215
+ // Manifests are appended rather than scored: they are short, they are the
216
+ // only place a binary's name is stated outright, and a compiled agent has no
217
+ // source file that could win a score.
218
+ for (const rel of tree) {
219
+ if (!MANIFESTS.includes(path.basename(rel))) continue;
220
+ if (best.some((entry) => entry.path === rel)) continue;
221
+ try {
222
+ const head = readFileSync(path.join(root, rel), "utf8");
223
+ best.push({ path: rel, score: 1, head: head.slice(0, 4_000) });
224
+ } catch {
225
+ continue;
226
+ }
227
+ if (best.length >= MAX_SHORTLIST + 4) break;
228
+ }
229
+ return best;
230
+ }
231
+
232
+ // ── Asking ──────────────────────────────────────────────────────────────
233
+
234
+ /**
235
+ * One bounded call, not a conversation.
236
+ *
237
+ * `/cli/agent-entry` exists rather than the workbench agent because this wants
238
+ * a parser, not a chatbot: the workbench agent carries the product's system
239
+ * prompt and its whole tool set, and asked for a JSON object about a pasted
240
+ * file tree it will argue with that prompt and sometimes call a tool. One
241
+ * request with a response schema and a cost ceiling is the same model from the
242
+ * customer's seat and a far more predictable answer.
243
+ */
244
+ async function askForEntry(args, { root, tree, files, namedSymbol = "", log }) {
245
+ const key = requireKey(args);
246
+ const controller = new AbortController();
247
+ const timer = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS);
248
+ try {
249
+ log("Reading this repository to find the code that runs your agent…");
250
+ const found = await callBackendJson(args, "POST", "/cli/agent-entry", {
251
+ token: key,
252
+ json: {
253
+ project_name: path.basename(root),
254
+ tree,
255
+ files: files.map((f) => ({ path: f.path, head: f.head })),
256
+ entry_module: ENTRY_MODULE,
257
+ entry_symbol: ENTRY_SYMBOL,
258
+ named_symbol: namedSymbol,
259
+ },
260
+ signal: controller.signal,
261
+ });
262
+ if (!found.ok) {
263
+ // 404 is the one worth naming: it means the deployment predates this
264
+ // command, which is a version problem rather than a repository problem
265
+ // and has a different fix.
266
+ if (found.status_code === 404) {
267
+ throw new TargetError(
268
+ `This PreMan deployment cannot search a repository yet. Name the entry point ` +
269
+ `yourself with --entry <file>:<symbol>, or point at a running agent with --target <url>.`
270
+ );
271
+ }
272
+ throw new TargetError(
273
+ `PreMan could not read this repository (${found.status_code}): ` +
274
+ `${found.detail || found.raw || "no reason given"}`
275
+ );
276
+ }
277
+ return found;
278
+ } catch (error) {
279
+ if (error?.name === "AbortError") {
280
+ throw new TargetError(
281
+ `PreMan took longer than ${SEARCH_TIMEOUT_MS / 1000}s to read this repository. ` +
282
+ `Name the entry point yourself with --entry <file>:<symbol>.`
283
+ );
284
+ }
285
+ throw error;
286
+ } finally {
287
+ clearTimeout(timer);
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Whether an answer is good enough to put in front of somebody.
293
+ *
294
+ * Refusing is the useful failure. A wrong entry point does not announce itself
295
+ * -- it produces a run where every case fails for a reason that reads like the
296
+ * agent misbehaving -- so a low-confidence guess costs more than no answer.
297
+ */
298
+ export function rejectionFor(found, tree) {
299
+ if (!found || found.found === false) return "it could not find an agent entry point here";
300
+ // A shape is what makes the answer usable now, not a returned file: the
301
+ // caller builds the wrapper, and `other` is the only shape that still needs
302
+ // code from the model.
303
+ const shape = String(found.shape || "");
304
+ if (shape === "other" || !shape) {
305
+ const source = found.turn_source || found.shim_source;
306
+ if (!source || typeof source !== "string") {
307
+ return "it could not say how to call the agent";
308
+ }
309
+ }
310
+ // `cli_subprocess` names a command rather than a file, so a file check would
311
+ // refuse every compiled agent.
312
+ if (shape !== "cli_subprocess") {
313
+ if (!found.entry_file || !tree.includes(found.entry_file)) {
314
+ return `it named ${found.entry_file || "a file"}, which is not in this repository`;
315
+ }
316
+ } else if (!Array.isArray(found.command) || !found.command.length) {
317
+ return "it said the agent is a program to run but did not say how to run it";
318
+ }
319
+ if (typeof found.confidence === "number" && found.confidence < MIN_CONFIDENCE) {
320
+ return `it was only ${Math.round(found.confidence * 100)}% sure`;
321
+ }
322
+ const others = Array.isArray(found.alternatives) ? found.alternatives : [];
323
+ if (others.length && typeof found.confidence === "number" && found.confidence < 0.8) {
324
+ return "several files could be the entry point and none of them stood out";
325
+ }
326
+ return "";
327
+ }
328
+
329
+ // ── Checking, before a person is asked to read it ───────────────────────
330
+
331
+ function entryPaths(projectPath) {
332
+ // An approved `.preman.json` wins, then the repository, then where we stand.
333
+ // The middle step is what makes `--entry src/agent.py` work from a
334
+ // subdirectory on a first run, when there is no config to find yet.
335
+ const root = findConfigRoot(projectPath) || findRepoRoot(projectPath) || projectPath;
336
+ const dir = path.join(root, ENTRY_DIR);
337
+ return { root, dir, file: path.join(dir, ENTRY_FILE) };
338
+ }
339
+
340
+ /**
341
+ * Compile it, import it, and make it answer once.
342
+ *
343
+ * These are the checks the server used to run before a callable target was
344
+ * allowed, moved to the machine that has the code. The server cannot do them
345
+ * for a file on somebody's laptop, and skipping them would mean discovering a
346
+ * broken wrapper eighteen turns into a paid run.
347
+ */
348
+ export async function validateShim(file, { projectPath, log }) {
349
+ const { dir } = entryPaths(projectPath);
350
+ const resolved = path.resolve(file);
351
+ if (path.relative(dir, resolved).startsWith("..")) {
352
+ return { ok: false, why: `it would write outside ${ENTRY_DIR}/` };
353
+ }
354
+
355
+ const { resolveInterpreter } = await import("./eval.js");
356
+ let python;
357
+ try {
358
+ python = resolveInterpreter({ projectPath, log: () => {} });
359
+ } catch (error) {
360
+ return { ok: false, why: error.message };
361
+ }
362
+
363
+ const compiled = spawnSync(python.command, [...python.argv, "-m", "py_compile", resolved], {
364
+ encoding: "utf8",
365
+ timeout: PROBE_TIMEOUT_MS,
366
+ });
367
+ if (compiled.status !== 0) {
368
+ return { ok: false, why: `it does not compile: ${(compiled.stderr || "").trim().slice(-400)}` };
369
+ }
370
+
371
+ // Imported the way assert-ai imports it -- bare module name off the import
372
+ // root -- so a wrapper that only works when run as a script fails here
373
+ // rather than inside the harness.
374
+ const probe = [
375
+ "import inspect, json, sys",
376
+ `sys.path.insert(0, ${JSON.stringify(dir)})`,
377
+ `import ${ENTRY_MODULE} as m`,
378
+ `fn = getattr(m, ${JSON.stringify(ENTRY_SYMBOL)})`,
379
+ 'if "history" not in inspect.signature(fn).parameters:',
380
+ ' print("PREMAN_PROBE:" + json.dumps({"ok": False, "why": "the wrapper takes no `history` argument, which assert-ai needs to pass a conversation"}));'
381
+ + ' sys.exit(0)',
382
+ 'out = fn("Hello", history=[])',
383
+ 'text = out.get("response") if isinstance(out, dict) else out',
384
+ 'ok = isinstance(text, str) and bool(text.strip())',
385
+ 'print("PREMAN_PROBE:" + json.dumps({"ok": ok, "reply": (text if isinstance(text, str) else repr(out))[:400],'
386
+ + ' "why": "" if ok else "it ran but returned no text"}))',
387
+ ].join("\n");
388
+
389
+ const ran = spawnSync(python.command, [...python.argv, "-c", probe], {
390
+ encoding: "utf8",
391
+ timeout: PROBE_TIMEOUT_MS,
392
+ cwd: projectPath,
393
+ });
394
+ if (ran.error?.code === "ETIMEDOUT") {
395
+ return { ok: false, why: "it did not answer within 60 seconds" };
396
+ }
397
+ const line = String(ran.stdout || "")
398
+ .split("\n")
399
+ .find((value) => value.startsWith("PREMAN_PROBE:"));
400
+ if (!line) {
401
+ return { ok: false, why: `it failed to run: ${(ran.stderr || "").trim().slice(-400)}` };
402
+ }
403
+ const verdict = JSON.parse(line.slice("PREMAN_PROBE:".length));
404
+ return verdict.ok ? { ok: true, reply: verdict.reply } : { ok: false, why: verdict.why };
405
+ }
406
+
407
+ // ── The review ──────────────────────────────────────────────────────────
408
+
409
+ function ask(question) {
410
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
411
+ return new Promise((resolve) => rl.question(question, (answer) => {
412
+ rl.close();
413
+ resolve(String(answer || "").trim().toLowerCase());
414
+ }));
415
+ }
416
+
417
+ /**
418
+ * Case preserved, unlike `ask`.
419
+ *
420
+ * A yes/no answer can be lowercased; a command line cannot -- paths, flags and
421
+ * binary names are case-sensitive, and `ask` would quietly turn `MyTool -X`
422
+ * into something that does not exist.
423
+ */
424
+ function askRaw(question) {
425
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
426
+ return new Promise((resolve) => rl.question(question, (answer) => {
427
+ rl.close();
428
+ resolve(String(answer || "").trim());
429
+ }));
430
+ }
431
+
432
+ /**
433
+ * A typed command line, as argv.
434
+ *
435
+ * Quotes are honoured so a path with a space survives; nothing else about a
436
+ * shell is interpreted. Deliberately not handed to a shell at all -- the
437
+ * wrapper spawns argv directly, so `&&`, redirects and globs are not available
438
+ * and cannot be smuggled in either.
439
+ */
440
+ export function splitCommand(text) {
441
+ const parts = [];
442
+ const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
443
+ let match;
444
+ while ((match = pattern.exec(String(text || ""))) !== null) {
445
+ parts.push(match[1] ?? match[2] ?? match[3]);
446
+ }
447
+ return parts.filter((part) => part.length);
448
+ }
449
+
450
+ /**
451
+ * How the agent is run, for a target that is a program rather than a module.
452
+ *
453
+ * A manifest names the binary and never its calling convention: `Cargo.toml`
454
+ * says the binary is `codex`, and nothing in the repository says a turn is
455
+ * `codex exec --json -`. So this is asked rather than inferred, once, and then
456
+ * lives in the wrapper that `.preman.json` records -- which is what stops it
457
+ * being asked again.
458
+ *
459
+ * The model's own suggestion is kept when it made one. It is shown in the
460
+ * wrapper at review time and exercised by the probe, so a wrong guess is
461
+ * caught before anything is approved rather than trusted here.
462
+ */
463
+ async function ensureCommand(found, { args, log }) {
464
+ if (String(found.shape || "") !== "cli_subprocess") return found;
465
+
466
+ const named = splitCommand(args.value("--command", ""));
467
+ if (named.length) return { ...found, command: named };
468
+ if (Array.isArray(found.command) && found.command.length) return found;
469
+
470
+ const headless =
471
+ !process.stdout.isTTY || args.has("--headless") || args.has("--no-wait");
472
+ if (headless) {
473
+ throw new TargetError(
474
+ `${found.entry_file || "This repository"} looks like an agent that is run as a ` +
475
+ `program rather than imported, and PreMan could not tell how to run it. ` +
476
+ `Re-run with --command "<how one turn is run>", for example ` +
477
+ `--command "mytool exec --json -".`
478
+ );
479
+ }
480
+
481
+ log("");
482
+ log(`This agent is a program rather than a module${found.framework ? ` (${found.framework})` : ""}.`);
483
+ log("PreMan needs the command that runs ONE turn, reading the prompt on stdin.");
484
+ log(' for example: codex exec --json -');
485
+ const typed = await askRaw(" command: ");
486
+ const command = splitCommand(typed);
487
+ if (!command.length) {
488
+ throw new TargetError(
489
+ `No command given, so nothing was written. Re-run and name it, or use ` +
490
+ `--command "<how one turn is run>".`
491
+ );
492
+ }
493
+ return { ...found, command };
494
+ }
495
+
496
+ async function review(found, source, reply, { log }) {
497
+ const numbered = source
498
+ .split("\n")
499
+ .map((line, index) => ` ${String(index + 1).padStart(3)} │ ${line}`)
500
+ .join("\n");
501
+
502
+ log("");
503
+ log(`PreMan thinks your agent is ${found.entry_file}${found.entry_symbol ? `:${found.entry_symbol}` : ""}` +
504
+ `${found.framework ? ` (${found.framework})` : ""}.`);
505
+ if (found.reasoning) log(` ${found.reasoning}`);
506
+ log("");
507
+ log(`It wrote this wrapper, which is what the eval will call:`);
508
+ log("");
509
+ log(numbered);
510
+ log("");
511
+ log(`Asked "Hello", it answered: ${JSON.stringify(reply)}`);
512
+ log("");
513
+
514
+ const answer = await ask("Use this? [y]es / [n]o / [e]dit it first: ");
515
+ return answer.startsWith("y") ? "yes" : answer.startsWith("e") ? "edit" : "no";
516
+ }
517
+
518
+ // ── Writing it down ─────────────────────────────────────────────────────
519
+
520
+ function writeShim(projectPath, source) {
521
+ const { root, dir, file } = entryPaths(projectPath);
522
+ mkdirSync(dir, { recursive: true });
523
+ writeFileSync(file, source.endsWith("\n") ? source : `${source}\n`, "utf8");
524
+ ignoreEntryDir(root);
525
+ return { root, dir, file };
526
+ }
527
+
528
+ /**
529
+ * Keep the wrapper out of version control.
530
+ *
531
+ * It names absolute-ish local facts (which interpreter, which framework build)
532
+ * and it is regenerated per machine, so committing it would put one developer's
533
+ * environment in everybody's checkout. `.preman.json` is the part that travels.
534
+ */
535
+ function ignoreEntryDir(root) {
536
+ const file = path.join(root, ".gitignore");
537
+ const line = `${ENTRY_DIR}/`;
538
+ let body = "";
539
+ try {
540
+ body = readFileSync(file, "utf8");
541
+ } catch {
542
+ body = "";
543
+ }
544
+ if (body.split("\n").some((value) => value.trim() === line)) return false;
545
+ writeFileSync(file, `${body}${body && !body.endsWith("\n") ? "\n" : ""}${line}\n`, "utf8");
546
+ return true;
547
+ }
548
+
549
+ function record(found, file, root, source, { language, alsoWrote = [] }) {
550
+ const sha256 = createHash("sha256").update(source).digest("hex");
551
+ // Hashed from disk rather than from what was generated: the point of the
552
+ // record is to notice a later edit, and a person offered `[e]dit it first`
553
+ // may have changed either file between generation and approval.
554
+ const files = [];
555
+ for (const extra of alsoWrote) {
556
+ if (!existsSync(extra)) continue;
557
+ files.push({
558
+ path: path.relative(root, extra).split(path.sep).join("/"),
559
+ sha256: createHash("sha256").update(readFileSync(extra, "utf8")).digest("hex"),
560
+ });
561
+ }
562
+ const entry = {
563
+ file: path.relative(root, file).split(path.sep).join("/"),
564
+ ref: ENTRY_REF,
565
+ sysPath: ENTRY_DIR,
566
+ sha256,
567
+ files,
568
+ // How the wrapper was built. Recorded because it is the one fact that
569
+ // explains the file's contents later: "this came from the langgraph
570
+ // template" is a different thing to debug than "a model wrote this".
571
+ shape: String(found.shape || "other"),
572
+ language,
573
+ framework: String(found.framework || ""),
574
+ entryFile: String(found.entry_file || ""),
575
+ approvedAt: new Date().toISOString(),
576
+ };
577
+ saveEntry(entry, root);
578
+ return entry;
579
+ }
580
+
581
+ // ── The two ways in ─────────────────────────────────────────────────────
582
+
583
+ /**
584
+ * Search, review, write, remember.
585
+ *
586
+ * Returns a discovery target so a caller can go straight on to the run. The
587
+ * order matters: nothing is written until the wrapper has compiled and answered,
588
+ * and nothing is recorded until a person has said yes.
589
+ */
590
+ export async function searchForEntry({ projectPath, args, log = () => {} }) {
591
+ requireKey(args);
592
+ const { root } = entryPaths(projectPath);
593
+ const tree = repoTree(root);
594
+ if (!tree.length) {
595
+ throw new TargetError(`There are no source files under ${root} to read.`);
596
+ }
597
+ const files = shortlist(root, tree);
598
+
599
+ let found = await askForEntry(args, { root, tree, files, log });
600
+ found = await ensureCommand(found, { args, log });
601
+ const rejection = rejectionFor(found, tree);
602
+ if (rejection) {
603
+ throw new TargetError(
604
+ `PreMan read this repository and ${rejection}, so nothing was written. ` +
605
+ `The files it considered were: ${files.slice(0, 5).map((f) => f.path).join(", ")}. ` +
606
+ `Name the entry point yourself with --entry <file>:<symbol>, or point at a running ` +
607
+ `agent with --target <url>.` +
608
+ (found?.reasoning ? `\n\nIt said: ${found.reasoning}` : "")
609
+ );
610
+ }
611
+
612
+ return installShim(found, wrapperFor(found, tree), { projectPath, args, log });
613
+ }
614
+
615
+ /**
616
+ * `--entry src/agent.py:graph`: skip the search, keep the review.
617
+ *
618
+ * The caller has answered the expensive question -- which file -- but the
619
+ * wrapper still has to be written and it still has to be right, so it goes
620
+ * through the same generation and the same gate. What is saved is the model
621
+ * reading the whole tree to find something already known.
622
+ */
623
+ export async function entryFromNamedSymbol(named, { projectPath, args, log = () => {} }) {
624
+ // No key check here. A malformed `--entry`, or one naming a file that is not
625
+ // there, is wrong whoever you are -- reporting it as an auth problem would
626
+ // send somebody looking for the wrong fix. `askForEntry` asks for the
627
+ // credential at the point it actually needs one.
628
+ // `--entry src/agent.py` and `--entry src/agent.py:graph` are both accepted.
629
+ // The symbol is a preference passed to the search, not a requirement, so
630
+ // demanding it asked for a fact that was then discarded. A colon only starts
631
+ // a symbol when what follows is an identifier -- otherwise `C:/repo/agent.py`
632
+ // parses as the file `C` with the symbol `/repo/agent.py`.
633
+ const at = named.lastIndexOf(":");
634
+ const tail = at > 0 ? named.slice(at + 1) : "";
635
+ const hasSymbol = /^[A-Za-z_][A-Za-z0-9_]*$/.test(tail);
636
+ const file = hasSymbol ? named.slice(0, at) : named;
637
+ const symbol = hasSymbol ? tail : "";
638
+ if (!file) {
639
+ throw new TargetError(
640
+ `--entry wants <file> or <file>:<symbol>, for example --entry src/agent.py:graph`
641
+ );
642
+ }
643
+ const { root } = entryPaths(projectPath);
644
+ // An absolute path is a perfectly natural thing to paste, and `path.join`
645
+ // silently mangles one into `<root>/mnt/c/...` -- which then reports as "does
646
+ // not exist" and sends somebody looking for a missing file rather than a
647
+ // mishandled argument.
648
+ const full = path.isAbsolute(file) ? file : path.join(root, file);
649
+ if (!existsSync(full)) {
650
+ throw new TargetError(`--entry names ${file}, which does not exist under ${root}.`);
651
+ }
652
+ // The model is shown, and later asked about, the repository-relative form:
653
+ // `rejectionFor` checks the answer's `entry_file` against the tree it was
654
+ // given, and that tree is always relative.
655
+ const rel = path.relative(root, full).split(path.sep).join("/");
656
+ if (rel.startsWith("..")) {
657
+ throw new TargetError(
658
+ `--entry names ${file}, which is outside ${root}. An eval measures the ` +
659
+ `agent in this repository, so the entry point has to be in it.`
660
+ );
661
+ }
662
+
663
+ const head = readFileSync(full, "utf8").split("\n").slice(0, MAX_HEAD_LINES).join("\n");
664
+ let found = await askForEntry(args, {
665
+ root,
666
+ tree: [rel],
667
+ files: [{ path: rel, head: head.slice(0, MAX_HEAD_BYTES) }],
668
+ namedSymbol: symbol,
669
+ log,
670
+ });
671
+ found = await ensureCommand(found, { args, log });
672
+ const rejection = rejectionFor(found, [rel]);
673
+ if (rejection) {
674
+ throw new TargetError(
675
+ `PreMan could not write a wrapper for ${symbol ? `${file}:${symbol}` : file} -- ${rejection}.` +
676
+ (found?.reasoning ? `\n\nIt said: ${found.reasoning}` : "")
677
+ );
678
+ }
679
+ return installShim(found, wrapperFor(found, [rel]), { projectPath, args, log });
680
+ }
681
+
682
+ /**
683
+ * The wrapper for this answer: from the template, or from the model for `other`.
684
+ *
685
+ * The template path writes no model-authored code at all, which is what makes
686
+ * the output byte-identical between runs on the same repository. The `other`
687
+ * path is the escape hatch, and it is the only one where a returned string
688
+ * becomes executable -- so it stays behind the same review gate it always was.
689
+ */
690
+ function wrapperFor(found, tree) {
691
+ const shape = String(found.shape || "");
692
+ if (SHAPES.has(shape)) {
693
+ try {
694
+ return buildWrapper(found, { tree });
695
+ } catch (error) {
696
+ if (error instanceof TemplateError) {
697
+ throw new TargetError(`PreMan could not build a wrapper: ${error.message}`);
698
+ }
699
+ throw error;
700
+ }
701
+ }
702
+ // An answer from a deployment that predates shapes still carries a whole
703
+ // file. Honoured rather than refused: a CLI newer than its backend is the
704
+ // normal direction of skew, and refusing would make an upgrade break a
705
+ // working install.
706
+ if (found.shim_source) {
707
+ return { shim: String(found.shim_source), bridge: String(found.bridge_source || "") };
708
+ }
709
+ const body = String(found.turn_source || "")
710
+ .split("\n")
711
+ .map((line) => (line.trim() ? ` ${line.replace(/^\s{0,4}/, "")}` : line))
712
+ .join("\n");
713
+ try {
714
+ return buildFreeform(found, { tree, turnSource: body });
715
+ } catch (error) {
716
+ if (error instanceof TemplateError) {
717
+ throw new TargetError(`PreMan could not build a wrapper: ${error.message}`);
718
+ }
719
+ throw error;
720
+ }
721
+ }
722
+
723
+ async function installShim(found, built, { projectPath, args, log }) {
724
+ const { root, dir, file } = writeShim(projectPath, built.shim);
725
+ const alsoWrote = [];
726
+ if (built.bridge) {
727
+ // Beside the wrapper, inside `.preman/`, so the same digest check and the
728
+ // same gitignore cover both halves of a two-file target.
729
+ const bridge = path.join(dir, BRIDGE_FILE);
730
+ writeFileSync(bridge, built.bridge, "utf8");
731
+ alsoWrote.push(bridge);
732
+ }
733
+ const checked = await validateShim(file, { projectPath, log });
734
+ if (!checked.ok) {
735
+ throw new TargetError(
736
+ `PreMan wrote ${path.relative(root, file)} but ${checked.why}. It has been left in ` +
737
+ `place so you can read it. Fix it and re-run, or name a different entry point with ` +
738
+ `--entry <file>:<symbol>.`
739
+ );
740
+ }
741
+
742
+ const verdict = await review(found, readFileSync(file, "utf8"), checked.reply, { log });
743
+ if (verdict === "edit") {
744
+ throw new TargetError(
745
+ `Left ${path.relative(root, file)} in place for you to edit. Re-run when it is ready ` +
746
+ `and it will be checked again.`
747
+ );
748
+ }
749
+ if (verdict !== "yes") {
750
+ throw new TargetError(
751
+ `Not used. ${path.relative(root, file)} is still there if you want to look at it; ` +
752
+ `delete it, or name a different entry point with --entry <file>:<symbol>.`
753
+ );
754
+ }
755
+
756
+ const finalSource = readFileSync(file, "utf8");
757
+ const language = String(found.language || "python").toLowerCase() === "node" ? "node" : "python";
758
+ const entry = record(found, file, root, finalSource, { language, alsoWrote });
759
+ log(`Approved. ${REPO_CONFIG} remembers it, so this will not be asked again.`);
760
+
761
+ return verifyEntry({ ...entry, root });
762
+ }