@magland/gentl 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,16 +33,17 @@ Options:
33
33
 
34
34
  - `--image IMG`: the Docker image for the sandbox. The default is built on first use from [sandbox/Dockerfile](sandbox/Dockerfile) and tagged by its content (`gentl-sandbox:<hash>`).
35
35
  - `--host`: run on this machine in a fresh temp directory instead of Docker, for software that can't run in a container (for example a licensed MATLAB).
36
+ - `--matlab-on-host`: `--host` set up for the MATLAB installed on this machine; see Host mode.
36
37
  - `--confirm`: ask before each action. This is the default with `--host`; Docker runs are unattended unless you pass it.
37
38
  - `--yes`: with `--host`, run actions without asking.
38
39
  - `--model M`: the agent's model, default `anthropic/claude-haiku-5.5`.
39
40
  - `--evaluator-model M`: the evaluator's model, same default.
40
- - `--upload`: upload each finished run to the hub its task came from.
41
+ - `--upload`: upload each run that finishes, reaches the turn limit or is aborted to the hub its task came from.
41
42
  - `--yes`: don't ask before running a hub task (and, with `--host`, before each action).
42
43
  - `--backend B`: `openrouter` (default) or `claude-code`; see Backends.
43
44
  - `--local` / `--remote`: serve clones of the task's repo from your checkout (the default inside a git repo), or clone the published repo; see Local copy.
44
45
  - `--base-url URL`: any OpenAI-compatible endpoint (default `https://openrouter.ai/api/v1`). The key in `OPENROUTER_API_KEY` is sent to it.
45
- - `--max-turns N`: default 50.
46
+ - `--max-turns N`: default 250.
46
47
  - `--repeat N`: run each task N times; see Repeated runs.
47
48
  - `--keep`: keep the container or temp workspace instead of deleting it.
48
49
 
@@ -68,13 +69,19 @@ The image and its id are recorded in `summary.json`.
68
69
 
69
70
  The agent works in `work/` inside a fresh temp directory, with `HOME` set to a sibling `home/` so it does not see your git config, caches or credentials. Only a few environment variables are passed through (`PATH`, locale, proxies); API keys and tokens are not. Each command runs in a fresh bash shell in its own process group, with no input and a 300 s timeout. The workspace is deleted at the end unless `--keep` is given.
70
71
 
71
- The agent can still reach anything on your machine that you can, so by default gentl shows each action and asks `ok? [y/n]` before running it. On `n` you can give a reason, which is passed back to the agent. Ctrl-C while a command runs stops that command (the agent is told); Ctrl-C at any other time ends the run, and the run directory is still written. This applies to Docker runs too.
72
+ The agent can still reach anything on your machine that you can, so by default gentl shows each action and asks `ok? [y/n]` before running it. On `n` you can give a reason, which is passed back to the agent.
73
+
74
+ Some licensed software keeps its licensing state in your home directory, which the agent's empty HOME does not have. `--share-home PATH` (repeatable) links that part of your real home directory into the agent's HOME, read and write, leaving the rest hidden; `--pass-env NAME` (repeatable) passes one more environment variable through. Both are printed at the start and recorded in `summary.json`. For MATLAB there is a shortcut, `--matlab-on-host`: it implies `--host` and shares `.matlab`, `.MathWorks` and `.MATLABConnector` (those that exist), which MATLAB's licensing needs (tested with R2025b and online licensing; without them MATLAB fails with licensing error 5201). It warns if there is no `matlab` command on your PATH. As with `--host`, add `--yes` to run without confirming each action:
75
+
76
+ ```
77
+ gentl run TASK --matlab-on-host --yes
78
+ ```
72
79
 
73
80
  Host mode also inherits whatever is installed on your machine (compilers, Python packages, uv), so setup steps can succeed without the docs being followed, and reading effort is not comparable with Docker runs.
74
81
 
75
82
  ### Suites
76
83
 
77
- `gentl run DIR` runs every `*.md` file in the directory, in name order, skipping `README.md` and files starting with `_`. The task name is the file name. Runs go to `runs/<timestamp>-<suite>/<run>/`, and at the end gentl prints a results table (verdict, self-report, turns, read tokens, notes max, questions, cost) and writes it to `results.md` and `results.json` in the suite directory. Ctrl-C that ends a run skips the remaining runs.
84
+ `gentl run DIR` runs every `*.md` file in the directory, in name order, skipping `README.md` and files starting with `_`. The task name is the file name. Runs go to `runs/<timestamp>-<suite>/<run>/`, and at the end gentl prints a results table (verdict, self-report, turns, read tokens, notes max, questions, cost) and writes it to `results.md` and `results.json` in the suite directory. Aborting a run with Ctrl-C skips the remaining runs.
78
85
 
79
86
  ### Repeated runs
80
87
 
@@ -90,12 +97,15 @@ Each turn the model sees only the system prompt, the task, its notes, and its la
90
97
  {"notes_keep": false, "notes": "...", "notes_edit": {"old": "...", "new": "..."}, "notes_append": "...", "action": {...}}
91
98
  ```
92
99
 
93
- The notes are the agent's working memory, meant to mimic how much a person can hold in their head at once, so the agent is asked to keep them small. `notes_keep` and `action` are required. Each turn the agent either updates its notes (`notes_keep: false` with one or more of the operations, applied in order: rewrite, edit, append) or keeps them as they are (`notes_keep: true`). Making the choice explicit matters: without it, models often wrote the action first and skipped the notes, then forgot what they had learned. If a response makes no notes decision anyway, gentl asks again within the same turn, once, with a short reminder (counted as reading and recorded as `notes_reminders`).
100
+ The notes are the agent's working memory and its record of what it has done. The prompt asks for generous, specific notes (each step and its outcome, what was read and what it said, exact commands that worked, what failed, the plan), grown with append and edit rather than rewritten, since agents with thin notes reread files and rerun commands in circles. The prompt gives a stable layout (Goal, Done, Learned, Commands, Plan) and a sense of size (several hundred to a couple of thousand words on a long task), and no longer tells the agent that notes are counted. When a rewrite shrinks notes of 100 tokens or more by over half, the next result says so (`notes_shrink_warnings`); `notes_tokens_max` shows how much the agent carried. (An earlier version asked for small notes, to mimic how much a person holds in their head; that may come back once the agent stays on course with less.) `notes_keep` and `action` are required. Each turn the agent either updates its notes (`notes_keep: false` with one or more of the operations, applied in order: rewrite, edit, append) or keeps them as they are (`notes_keep: true`). Making the choice explicit matters: without it, models often wrote the action first and skipped the notes, then forgot what they had learned. If a response makes no notes decision anyway, gentl asks again within the same turn, once, with a short reminder (counted as reading and recorded as `notes_reminders`).
101
+
102
+ A memoryless agent can also go in circles, rereading the same sections because nothing tells it it has read them. Two things work against that. The prompt asks the agent to keep a short log in its notes of what it has read and what it gave, to check it before reading, and to act on a result in the turn it sees it (for code: read the whole region to change at once, then edit) rather than collecting a file piece by piece. And when the agent repeats an action it already took at any earlier turn, the result says so, naming those turns (counted as reading; `repeated_actions` in the summary).
94
103
 
95
104
  Actions:
96
105
 
97
- - `run {cmd, max_chars?}`: bash in the working directory. stdout and stderr are combined. Output longer than `max_chars` (default 2000, at most 20000) is cut in the middle, keeping the head and tail.
98
- - `write_file {path, content}` and `edit_file {path, old, new}` (`old` must occur exactly once).
106
+ - `run {cmd, max_chars?}`: bash in the working directory. stdout and stderr are combined. Output longer than `max_chars` (default 4000, at most 20000) is cut in the middle, keeping the head and tail, and the result's first line says so.
107
+ - `read_file {path, start_line?, end_line?}`: exactly the requested lines of a file, numbered, never cut in the middle; a range over 20000 characters stops at a line boundary and says where to go on. The prompt asks the agent to read files this way rather than with `cat` or `sed`, whose output, like any command's, is cut in the middle when long. Without it, an agent reading part of a file would get both ends of the range and not the middle, ask for the middle, lose the rest, and go round in circles.
108
+ - `write_file {path, content}` and `edit_file {path, old, new}` (`old` must occur exactly once). When `old` does not match, the error says why if it can: a backslash sequence in the code (`\n` in a string) written in the JSON as a real line break, or else the file's lines closest to it.
99
109
  - `ask {question}`: no one answers. The question is recorded as a gap in the docs, and the agent is told to decide from the docs and code.
100
110
  - `finish {success, summary, friction?}`: the summary cites the evidence of success; `friction` is the agent's own account of what was missing or unclear in the docs.
101
111
 
@@ -108,9 +118,13 @@ gentl asks for structured output (a JSON schema) so responses parse reliably; if
108
118
 
109
119
  The backend is recorded in `summary.json`. Runs with different backends are not directly comparable.
110
120
 
121
+ ### Stopping a run
122
+
123
+ Ctrl-C aborts the run wherever it is: a running command is killed and a model call cancelled. The run is recorded with stop reason `aborted`, evaluated (its verdict is always a failure, and the friction report covers what happened up to the abort), and, with `--upload`, reported to the hub. A second Ctrl-C during the evaluation skips it. The sandbox is removed in every case. A command that hangs is also stopped by the 300 s timeout, and the agent is told. SIGTERM and SIGHUP (the process killed, the terminal closed) end the run with stop reason `interrupted`, without an evaluation or upload; the run directory is still written.
124
+
111
125
  ## Evaluation
112
126
 
113
- After a run that finishes (or reaches the turn limit), gentl records the final state of the workspace: for each git repo, `git status`, the commits and `git diff` against its upstream, and untracked files; also any loose files in the working directory. Then an evaluator (one model call) reads the full log, with the parts of each result that the agent did not see marked as such, the questions asked, the agent's finish report and the final state. It writes:
127
+ After a run that finishes, reaches the turn limit or is aborted, gentl records the final state of the workspace: for each git repo, `git status`, the commits and `git diff` against its upstream, and untracked files; also any loose files in the working directory. Then an evaluator (one model call) reads the full log, with the parts of each result that the agent did not see marked as such, the questions asked, the agent's finish report and the final state. It writes:
114
128
 
115
129
  - a verdict: did the agent actually accomplish the task, judged from the evidence rather than the agent's claim;
116
130
  - a friction report: docs used, what was missing or wrong, where the agent guessed, signs of prior knowledge (commands or APIs it used that never appeared in anything it read), and the questions it asked.
@@ -128,7 +142,7 @@ The evaluator's tokens are not counted as reading effort. Reading effort should
128
142
  A hub ([gentl-hub](https://github.com/magland/gentl-hub); the public one is https://gentl-hub.jeremy-magland.workers.dev) holds tasks with titles, shows the command to run each, and collects uploaded runs. gentl contacts a hub only when given a hub task URL or asked to upload.
129
143
 
130
144
  - `gentl run https://<hub>/tasks/<id>` fetches the task, prints its title and text, and asks before running it (`--yes` skips this). Hub tasks always clone the published repo, never a local copy, so uploaded results match public code.
131
- - `--upload` uploads each run that finished (or hit the turn limit) to that hub; `gentl upload runs/<id>` uploads one later, and `gentl upload runs/<batch>` uploads every run in a batch. A run goes only to the hub its task came from.
145
+ - `--upload` uploads each run that finished, hit the turn limit or was aborted (Ctrl-C) to that hub; `gentl upload runs/<id>` uploads one later, and `gentl upload runs/<batch>` uploads every run in a batch. A run goes only to the hub its task came from.
132
146
  - The first upload to a hub asks for your name (shown with your runs) and the hub's passcode, and offers to remember them in `~/.config/gentl/hubs.json` (mode 0600). `GENTL_HUB_USER` and `GENTL_HUB_PASSCODE` override it.
133
147
  - The upload holds `summary.json`, `evaluation.json`, `final-state.md`, and each turn as the agent saw it (notes, action, shown result); full command output stays local.
134
148
 
package/dist/cli.js CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import * as fs from "node:fs";
3
+ import * as os from "node:os";
3
4
  import * as path from "node:path";
4
5
  import * as readline from "node:readline";
5
6
  import { parseArgs } from "node:util";
6
7
  import { checkDocker, ensureDefaultImage } from "./docker.js";
7
8
  import { fetchHubTask, hubCredentials, parseHubTaskUrl, runDirsIn, runHub, uploadRun } from "./hub.js";
9
+ import { resolveSharedHome } from "./host.js";
8
10
  import { findRepo, prepareLocalCopy, removeLocalCopy } from "./localcopy.js";
9
11
  import { evaluate, runTask } from "./run.js";
10
12
  import { runBatch, suiteTasks } from "./suite.js";
@@ -25,6 +27,11 @@ options:
25
27
  --image IMG Docker image for the sandbox (default: built from gentl's sandbox/Dockerfile)
26
28
  --host run in a fresh temp directory on this machine instead of Docker
27
29
  --confirm ask before each action (the default with --host)
30
+ --share-home PATH with --host, link this part of your home directory (e.g. .matlab) into the
31
+ agent's otherwise empty HOME; repeatable
32
+ --pass-env NAME with --host, pass this environment variable through as well; repeatable
33
+ --matlab-on-host use the MATLAB installed on this machine: implies --host and shares the
34
+ home folders MATLAB's licensing needs (.matlab, .MathWorks, .MATLABConnector)
28
35
  --yes don't ask: run a hub task without confirming it, and with --host, run actions
29
36
  without asking
30
37
  --upload upload each finished run to the hub its task came from
@@ -34,7 +41,7 @@ options:
34
41
  claude-code (your Claude Code login, via claude -p); default from
35
42
  GENTL_BACKEND, else openrouter
36
43
  --base-url URL OpenAI-compatible endpoint (default ${DEFAULT_BASE_URL})
37
- --max-turns N stop after N turns (default 50)
44
+ --max-turns N stop after N turns (default 250)
38
45
  --repeat N run each task N times and report the success rate and median read tokens
39
46
  --local serve clones of the task's repo from your checkout, uncommitted changes included
40
47
  (the default when the task file, suite or current directory is in a git repo)
@@ -43,6 +50,11 @@ options:
43
50
 
44
51
  The API key is read from OPENROUTER_API_KEY, in the environment or in ./.env.
45
52
  Runs are written to ./runs/<id>/.`;
53
+ /** The home folders a licensed MATLAB keeps its licensing state in (R2025b, online licensing). */
54
+ const MATLAB_HOME_DIRS = [".matlab", ".MathWorks", ".MATLABConnector"];
55
+ function onPath(cmd) {
56
+ return (process.env.PATH ?? "").split(path.delimiter).some((d) => d && fs.existsSync(path.join(d, cmd)));
57
+ }
46
58
  /** A variable from the environment, or from ./.env. */
47
59
  function envValue(name) {
48
60
  if (process.env[name])
@@ -71,13 +83,16 @@ async function run(argv) {
71
83
  confirm: { type: "boolean" },
72
84
  yes: { type: "boolean" },
73
85
  local: { type: "boolean" },
86
+ "share-home": { type: "string", multiple: true },
87
+ "matlab-on-host": { type: "boolean" },
88
+ "pass-env": { type: "string", multiple: true },
74
89
  upload: { type: "boolean" },
75
90
  remote: { type: "boolean" },
76
91
  model: { type: "string", default: DEFAULT_MODEL },
77
92
  "evaluator-model": { type: "string", default: DEFAULT_MODEL },
78
93
  backend: { type: "string" },
79
94
  "base-url": { type: "string", default: DEFAULT_BASE_URL },
80
- "max-turns": { type: "string", default: "50" },
95
+ "max-turns": { type: "string", default: "250" },
81
96
  repeat: { type: "string", default: "1" },
82
97
  keep: { type: "boolean" },
83
98
  help: { type: "boolean", short: "h" },
@@ -89,6 +104,12 @@ async function run(argv) {
89
104
  }
90
105
  if ((values.task === undefined) === (positionals.length !== 1))
91
106
  fail("give one task: a task file or -t TEXT");
107
+ // --matlab-on-host is --host plus MATLAB's home folders; set host first, since confirmation depends on it.
108
+ if (values["matlab-on-host"]) {
109
+ if (values.image)
110
+ fail("--matlab-on-host runs on this machine, so --image does not apply");
111
+ values.host = true;
112
+ }
92
113
  if (values.host && values.image)
93
114
  fail("--image applies to the Docker sandbox, not --host");
94
115
  if (values.confirm && values.yes)
@@ -106,8 +127,24 @@ async function run(argv) {
106
127
  fail("--repeat must be a positive integer");
107
128
  const modelFor = modelConfig(values.backend, values["base-url"]);
108
129
  let sandbox;
130
+ const shareHome = [...(values["share-home"] ?? []),
131
+ ...(values["matlab-on-host"] ? MATLAB_HOME_DIRS.filter((d) => fs.existsSync(path.join(os.homedir(), d))) : [])];
132
+ if (values["matlab-on-host"] && !onPath("matlab")) {
133
+ console.log("warning: --matlab-on-host, but there is no matlab command on your PATH, so the agent will not find one either");
134
+ }
135
+ const passEnv = values["pass-env"] ?? [];
136
+ if (!values.host && (shareHome.length || passEnv.length))
137
+ fail("--share-home and --pass-env apply only with --host");
138
+ for (const p of shareHome) {
139
+ try {
140
+ resolveSharedHome(p);
141
+ }
142
+ catch (e) {
143
+ fail(e.message);
144
+ }
145
+ }
109
146
  if (values.host) {
110
- sandbox = { kind: "host" };
147
+ sandbox = { kind: "host", shareHome, passEnv };
111
148
  if (!confirm)
112
149
  console.log("warning: host mode without confirmation; the agent's commands run on this machine unchecked");
113
150
  }
package/dist/evaluate.js CHANGED
@@ -52,6 +52,8 @@ function formatActionForEvaluator(a) {
52
52
  switch (a.type) {
53
53
  case "run":
54
54
  return `run:\n${fence(String(a.cmd), "bash")}`;
55
+ case "read_file":
56
+ return `read_file ${a.path}${a.start_line !== undefined || a.end_line !== undefined ? ` lines ${a.start_line ?? 1}-${a.end_line ?? "end"}` : ""}`;
55
57
  case "write_file":
56
58
  return `write_file ${a.path}:\n${fence(clip(String(a.content), RESULT_CAP))}`;
57
59
  case "edit_file":
@@ -91,7 +93,9 @@ export function buildEvaluatorInput(runDir) {
91
93
  const asks = (summary.asks ?? []);
92
94
  const finish = summary.finished
93
95
  ? `success: ${summary.self_reported_success}\nsummary: ${summary.self_report_summary}\nfriction: ${summary.self_report_friction ?? "(none given)"}`
94
- : `The agent did not finish (stop reason: ${summary.stop_reason}).`;
96
+ : summary.stop_reason === "aborted"
97
+ ? `The person running gentl aborted the run (Ctrl-C) after turn ${summary.turns}, before the agent finished. The verdict is therefore a failure; write the reasons and the friction report from what happened up to that point.`
98
+ : `The agent did not finish (stop reason: ${summary.stop_reason}).`;
95
99
  const lc = summary.local_copy
96
100
  ? ` Clones of ${summary.local_copy.origin} were served from the maintainer's local working copy (with any uncommitted changes), so the agent's clone may differ from the published repo and its origin URL is a local file:// path; this is intended, not a problem with the run.`
97
101
  : "";
@@ -111,8 +115,12 @@ export async function evaluateRun(runDir, cfg, signal, log) {
111
115
  catch {
112
116
  throw new Error(`could not parse the evaluator's response: ${reply.text.slice(0, 300)}`);
113
117
  }
118
+ // An aborted run is a failure whatever the evaluator says; its reasons and friction report still stand.
119
+ const aborted = JSON.parse(fs.readFileSync(path.join(runDir, "summary.json"), "utf8")).stop_reason === "aborted";
120
+ const reasons = String(parsed.reasons ?? "");
114
121
  const ev = {
115
- model: cfg.model, success: parsed.success, reasons: String(parsed.reasons ?? ""),
122
+ model: cfg.model, success: aborted ? false : parsed.success,
123
+ reasons: aborted ? `Aborted by the person running gentl before the agent finished. ${reasons}` : reasons,
116
124
  friction_report: String(parsed.friction_report ?? ""), structured_output: client.structured,
117
125
  usage: reply.usage, cost_usd: Number(reply.usage.cost ?? 0), input_chars: input.length, created: new Date().toISOString(),
118
126
  };
package/dist/host.js CHANGED
@@ -17,27 +17,51 @@ function killGroup(pid) {
17
17
  // already gone
18
18
  }
19
19
  }
20
+ /** A --share-home path: relative to HOME, inside it, and existing. Returns the absolute path or throws. */
21
+ export function resolveSharedHome(p) {
22
+ const home = os.homedir();
23
+ const abs = path.resolve(home, p.replace(/^~\//, ""));
24
+ if (abs === home || !abs.startsWith(home + path.sep))
25
+ throw new Error(`--share-home ${p}: give a path inside your home directory, such as .matlab`);
26
+ if (!fs.existsSync(abs))
27
+ throw new Error(`--share-home ${p}: ${abs} does not exist`);
28
+ return abs;
29
+ }
20
30
  export class HostSandbox extends Sandbox {
31
+ opts;
21
32
  kind = "host";
22
33
  root;
23
34
  workdir;
24
35
  env = {};
25
36
  groups = new Set(); // process groups of all commands, killed on remove()
26
- constructor() {
37
+ links = []; // links into the real HOME, removed before the workspace is deleted
38
+ constructor(opts = { shareHome: [], passEnv: [] }) {
27
39
  super();
40
+ this.opts = opts;
28
41
  this.root = fs.mkdtempSync(path.join(os.tmpdir(), "gentl-"));
29
42
  this.workdir = path.join(this.root, "work");
30
43
  const home = path.join(this.root, "home");
31
44
  fs.mkdirSync(this.workdir);
32
45
  fs.mkdirSync(home);
33
- for (const k of PASSTHROUGH_ENV)
46
+ // Shared parts of the real HOME are linked in, not copied, so programs that keep state there (MATLAB's
47
+ // licensing) see and update the real thing. Everything else in the real HOME stays out of reach.
48
+ for (const p of opts.shareHome) {
49
+ const real = resolveSharedHome(p);
50
+ const link = path.join(home, path.relative(os.homedir(), real));
51
+ fs.mkdirSync(path.dirname(link), { recursive: true });
52
+ fs.symlinkSync(real, link);
53
+ this.links.push(link);
54
+ }
55
+ for (const k of [...PASSTHROUGH_ENV, ...opts.passEnv])
34
56
  if (process.env[k] !== undefined)
35
57
  this.env[k] = process.env[k];
36
58
  Object.assign(this.env, { HOME: home, TERM: "dumb", NO_COLOR: "1", PAGER: "cat", GIT_PAGER: "cat",
37
59
  GIT_TERMINAL_PROMPT: "0" });
38
60
  }
39
61
  describe() {
40
- return `host, workspace ${this.root}`;
62
+ const shared = this.opts.shareHome.length ? `, sharing ~/${this.opts.shareHome.join(", ~/")} from your home` : "";
63
+ const env = this.opts.passEnv.length ? `, passing ${this.opts.passEnv.join(", ")}` : "";
64
+ return `host, workspace ${this.root}${shared}${env}`;
41
65
  }
42
66
  start(cmd) {
43
67
  const child = spawn("bash", ["-c", cmd], { cwd: this.workdir, env: this.env, detached: true,
@@ -52,6 +76,9 @@ export class HostSandbox extends Sandbox {
52
76
  remove() {
53
77
  for (const pid of this.groups)
54
78
  killGroup(pid);
79
+ // Unlink the shared parts of the real HOME first, so nothing below can reach into them.
80
+ for (const link of this.links)
81
+ fs.rmSync(link, { force: true });
55
82
  try {
56
83
  fs.rmSync(this.root, { recursive: true, force: true });
57
84
  }
package/dist/protocol.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // What the agent sees and how its responses are parsed.
2
- export const ACTION_TYPES = ["run", "write_file", "edit_file", "ask", "finish"];
2
+ export const ACTION_TYPES = ["run", "read_file", "write_file", "edit_file", "ask", "finish"];
3
+ export const READ_MAX_CHARS = 20000; // a read_file result stops at a line boundary before this
3
4
  export const ASK_RESULT = "No one is available to answer. Your question is recorded as a gap in the docs. Decide from the docs and code as best you can.";
4
- export const DEFAULT_MAX_CHARS = 2000;
5
+ export const DEFAULT_MAX_CHARS = 4000;
5
6
  export const MAX_MAX_CHARS = 20000;
6
7
  export const CMD_TIMEOUT_S = 300;
7
8
  export function systemPrompt(workdir, platform) {
@@ -9,18 +10,34 @@ export function systemPrompt(workdir, platform) {
9
10
 
10
11
  Treat the software as unfamiliar. Go by its docs and code, not by what you remember about it, since the version here may differ.
11
12
 
12
- This is a test of how readable the software and its docs are. We count the tokens you read (every result you see, plus your notes each turn) as a proxy for the effort a human newcomer would need, and we collect what was missing or unclear. So read in a targeted way (grep, sed -n, head, --help) rather than dumping whole files or long pages. When the docs and code don't answer something you need, don't keep searching or guess silently: use the ask action, then make a reasonable decision and move on. A gap in the docs is useful feedback, not a failure on your part.
13
+ This is a test of how readable the software and its docs are. We count the tokens of the results you read as a proxy for the effort a human newcomer would need, and we collect what was missing or unclear. So read in a targeted way (grep, sed -n, head, --help) rather than dumping whole files or long pages. When the docs and code don't answer something you need, don't keep searching or guess silently: use the ask action, then make a reasonable decision and move on. A gap in the docs is useful feedback, not a failure on your part.
13
14
 
14
- You have no memory between turns. Each turn you see only the task, your notes, and your last action with its result. The result is shown once and is gone on the next turn; your notes are all you carry forward. They are your working memory, like what a person can hold in their head at once. They start empty.
15
+ You have no memory between turns. Each turn you see only the task, your notes, and your last action with its result. The result is shown once and is gone on the next turn; your notes are all you carry forward. They are your working memory and your record of what you have done. They start empty.
15
16
 
16
- So every turn has two steps. First, update your notes: if the result (or the task, on the first turn) tells you something you will need later, such as a fact, a path, a command that worked, what is done or what is next, record it and set "notes_keep": false. Only if nothing is worth keeping, set "notes_keep": true. Second, choose the action. Keep the notes small, with only what you still need; they are counted every turn.
17
+ So every turn has two steps. First, update your notes with what the result (or the task, on the first turn) told you, and set "notes_keep": false; almost every result is worth at least a line (what you ran and what came of it), so set "notes_keep": true only if the result told you nothing worth keeping. Second, choose the action.
18
+
19
+ Be generous with notes. Forgetting is what costs: an agent with thin notes rereads files and reruns commands, and goes in circles. Notes of several hundred to a couple of thousand words are normal on a long task; a short note that forces you to read something again is the expensive choice. Record, specifically:
20
+ - each step you have taken and its outcome, as soon as the result confirms it (for example: "edit 1 applied to +pkg/list.m: added the --json flag at line 23; verified");
21
+ - what you have read and what it told you: file and line range or doc section, the key facts, and any exact snippets you will need again (a function signature, the lines you will edit);
22
+ - commands that worked, written out exactly, with the key part of their output; commands that failed, and why;
23
+ - your plan and the next step.
24
+ A layout that stays the same from turn to turn makes this easy, with new lines appended under each heading:
25
+ Goal: ...
26
+ Done: (each step and its outcome)
27
+ Learned: (file:lines and what they say; exact snippets)
28
+ Commands: (exact commands that worked, with key output; failures and why)
29
+ Plan: ...
30
+ Before acting, check whether your notes say it is already done or already read. Grow the notes with "notes_append" and "notes_edit" rather than rewriting them from scratch, so that nothing you still need is lost; rewrite only to reorganize, and remove things only once they are finished and will not be needed again.
31
+
32
+ You cannot hold a whole file in your notes, so don't collect one piece by piece. Instead, act on a result in the turn you see it: to change code, read the whole region you need at once and make the edit in your response to that result, copying the exact text to replace from it.
17
33
 
18
34
  Each turn, respond with one JSON object and nothing else:
19
35
  {"notes_keep": false, "notes": "...", "notes_edit": {"old": "...", "new": "..."}, "notes_append": "...", "action": {...}}
20
36
  "notes_keep" and "action" are required. With "notes_keep": false, give at least one of "notes" (replaces your notes), "notes_edit" (replaces text that occurs exactly once in them) and "notes_append" (adds a line at the end); they are applied in that order. "notes_keep": true leaves your notes as they are.
21
37
 
22
38
  Actions:
23
- - {"type": "run", "cmd": "..."}: run with bash in your working directory. Each command starts a fresh shell (cd and exported variables do not persist) with no input. stdout and stderr are combined; output longer than ${DEFAULT_MAX_CHARS} characters is cut in the middle (add "max_chars": N, at most ${MAX_MAX_CHARS}, to see more). Timeout ${CMD_TIMEOUT_S} s.
39
+ - {"type": "run", "cmd": "..."}: run with bash in your working directory. Each command starts a fresh shell (cd and exported variables do not persist) with no input. stdout and stderr are combined; output longer than ${DEFAULT_MAX_CHARS} characters is cut in the middle, keeping its start and end, and the result's first line says so (add "max_chars": N, at most ${MAX_MAX_CHARS}, to see more). Timeout ${CMD_TIMEOUT_S} s.
40
+ - {"type": "read_file", "path": "...", "start_line": N, "end_line": M}: show lines N to M of a file, numbered (both optional: the whole file, or from N to the end). It shows exactly the lines asked for and is never cut in the middle; a range over ${READ_MAX_CHARS} characters stops early and says where to go on. Use it, not cat or sed, to read files.
24
41
  - {"type": "write_file", "path": "...", "content": "..."}: create or overwrite a file. Relative paths are under your working directory.
25
42
  - {"type": "edit_file", "path": "...", "old": "...", "new": "..."}: replace text that occurs exactly once in the file.
26
43
  - {"type": "ask", "question": "..."}: ask the maintainers something the docs and code don't tell you. No one will answer during this test; the question is recorded as feedback on the docs.
@@ -41,6 +58,7 @@ export const RESPONSE_SCHEMA = obj({
41
58
  action: {
42
59
  anyOf: [
43
60
  obj({ type: actionType("run"), cmd: str, max_chars: { type: "integer" } }, ["type", "cmd"]),
61
+ obj({ type: actionType("read_file"), path: str, start_line: { type: "integer" }, end_line: { type: "integer" } }, ["type", "path"]),
44
62
  obj({ type: actionType("write_file"), path: str, content: str }, ["type", "path", "content"]),
45
63
  obj({ type: actionType("edit_file"), path: str, old: str, new: str }, ["type", "path", "old", "new"]),
46
64
  obj({ type: actionType("ask"), question: str }, ["type", "question"]),
@@ -156,6 +174,14 @@ export function validateAction(a) {
156
174
  if (a.max_chars !== undefined && typeof a.max_chars !== "number")
157
175
  return '"max_chars" must be a number';
158
176
  return null;
177
+ case "read_file":
178
+ if (!str("path"))
179
+ return 'read_file needs "path" (string)';
180
+ for (const k of ["start_line", "end_line"]) {
181
+ if (a[k] !== undefined && !(Number.isInteger(a[k]) && a[k] >= 1))
182
+ return `"${k}" must be a whole number, at least 1`;
183
+ }
184
+ return null;
159
185
  case "write_file":
160
186
  return str("path") && str("content") ? null : 'write_file needs "path" and "content" (strings)';
161
187
  case "edit_file":
@@ -177,6 +203,8 @@ export function describeAction(a) {
177
203
  switch (a.type) {
178
204
  case "run":
179
205
  return a.max_chars !== undefined && a.max_chars !== DEFAULT_MAX_CHARS ? `run (max_chars ${a.max_chars}): ${a.cmd}` : `run: ${a.cmd}`;
206
+ case "read_file":
207
+ return `read_file: ${a.path}${a.start_line !== undefined || a.end_line !== undefined ? ` (lines ${a.start_line ?? 1}-${a.end_line ?? "end"})` : ""}`;
180
208
  case "write_file":
181
209
  case "edit_file":
182
210
  return `${a.type}: ${a.path}`;
package/dist/run.js CHANGED
@@ -18,6 +18,8 @@ function formatAction(a) {
18
18
  switch (a.type) {
19
19
  case "run":
20
20
  return `### Action: run${a.max_chars !== undefined ? ` (max_chars ${a.max_chars})` : ""}\n${fence(String(a.cmd), "bash")}`;
21
+ case "read_file":
22
+ return `### Action: read_file \`${a.path}\`${a.start_line !== undefined || a.end_line !== undefined ? ` lines ${a.start_line ?? 1}-${a.end_line ?? "end"}` : ""}\n`;
21
23
  case "write_file":
22
24
  return `### Action: write_file \`${a.path}\`\n${fence(String(a.content))}`;
23
25
  case "edit_file":
@@ -45,6 +47,7 @@ function formatSummary(s) {
45
47
  `${s.parse_errors} / ${s.invalid_actions} / ${s.repeated_actions} / ${s.declined_actions}`],
46
48
  ["Questions asked", s.asks.length],
47
49
  ["Notes reminders (responses without a notes decision)", s.notes_reminders],
50
+ ["Notes shrink warnings (a rewrite lost over half the notes)", s.notes_shrink_warnings],
48
51
  ["Cost", `$${s.cost_usd.toFixed(4)}${s.backend === "claude-code" ? " (notional; Claude Code subscription)" : ""}`],
49
52
  ["Duration", `${Math.round(s.duration_s)} s`],
50
53
  ];
@@ -105,6 +108,8 @@ function actionLine(a) {
105
108
  switch (a.type) {
106
109
  case "run":
107
110
  return `run: ${firstLine(String(a.cmd), 90)}`;
111
+ case "read_file":
112
+ return `read_file: ${a.path}${a.start_line !== undefined || a.end_line !== undefined ? ` ${a.start_line ?? 1}-${a.end_line ?? "end"}` : ""}`;
108
113
  case "write_file":
109
114
  case "edit_file":
110
115
  return `${a.type}: ${a.path}`;
@@ -128,20 +133,23 @@ export async function runTask(opts) {
128
133
  const sb = opts.sandbox.kind === "docker"
129
134
  // The random suffix keeps names unique when two gentl processes start the same task in the same second.
130
135
  ? new DockerSandbox(`gentl-${runId.toLowerCase().replace(/[^a-z0-9_.-]/g, "-")}-${randomBytes(3).toString("hex")}`, opts.sandbox.image, lc ? [{ host: lc.mirrorDir, container: MIRROR_MOUNT }] : [])
131
- : new HostSandbox();
136
+ : new HostSandbox({ shareHome: opts.sandbox.shareHome, passEnv: opts.sandbox.passEnv });
132
137
  // Signal handlers go in as soon as the sandbox exists, so no signal can leave it behind.
133
- // Ctrl-C stops the running command, or cancels the model call and ends the run.
134
- // SIGTERM and SIGHUP end the run wherever it is; the run directory is still completed.
135
- // During cleanup further signals are ignored, so a second Ctrl-C cannot leave the sandbox behind.
138
+ // Ctrl-C aborts the run wherever it is (a running command is killed, a model call cancelled); the run is
139
+ // then evaluated as a failure and, with --upload, reported. SIGTERM and SIGHUP end it without either.
140
+ // The run directory is completed in every case. During cleanup further signals are ignored, so a
141
+ // second Ctrl-C cannot leave the sandbox behind.
136
142
  let abortModel = null;
137
- let stopRequested = false;
143
+ let abortRequested = false; // Ctrl-C
144
+ let stopRequested = false; // SIGTERM, SIGHUP
138
145
  let cleaningUp = false;
139
146
  const onSigint = () => {
140
147
  if (cleaningUp)
141
148
  return console.log(" still cleaning up (removing the sandbox); please wait");
142
- if (sb.interrupt())
143
- return;
144
- stopRequested = true;
149
+ if (!abortRequested)
150
+ console.log("\n aborting the run (Ctrl-C); it will be evaluated as a failure and, with --upload, reported");
151
+ abortRequested = true;
152
+ sb.interrupt();
145
153
  abortModel?.abort();
146
154
  };
147
155
  const onTerm = () => {
@@ -176,11 +184,13 @@ export async function runTask(opts) {
176
184
  base_url: opts.model.backend === "openrouter" ? opts.model.baseUrl : null,
177
185
  sandbox: sb.kind, image: sb instanceof DockerSandbox ? sb.image : null,
178
186
  image_id: sb instanceof DockerSandbox ? sb.imageId : null, confirm: opts.confirm,
187
+ host_share_home: opts.sandbox.kind === "host" ? opts.sandbox.shareHome : [],
188
+ host_pass_env: opts.sandbox.kind === "host" ? opts.sandbox.passEnv : [],
179
189
  local_copy: lc && !localCopyError ? localCopyInfo(lc) : null, structured_output: true, started: started.toISOString(), ended: "", duration_s: 0,
180
190
  system_prompt: system, overhead_tokens: ntok(system) + ntok(opts.task), turns: 0,
181
191
  read_tokens_total: 0, notes_tokens_total: 0, notes_tokens_max: 0, result_tokens_total: 0, result_tokens_max: 0,
182
192
  model_output_tokens: 0, provider_prompt_tokens: 0, provider_completion_tokens: 0, cost_usd: 0,
183
- parse_errors: 0, invalid_actions: 0, repeated_actions: 0, declined_actions: 0, notes_reminders: 0, asks: [],
193
+ parse_errors: 0, invalid_actions: 0, repeated_actions: 0, declined_actions: 0, notes_reminders: 0, notes_shrink_warnings: 0, asks: [],
184
194
  stop_reason: null, error: null, finished: false,
185
195
  self_reported_success: null, self_report_summary: null, self_report_friction: null, final_repos: null,
186
196
  };
@@ -203,10 +213,10 @@ export async function runTask(opts) {
203
213
  backend: opts.model.backend, base_url: s.base_url, sandbox: sb.kind, sandbox_description: sb.describe(), workdir: sb.workdir, confirm: opts.confirm, system_prompt: system });
204
214
  let notes = "";
205
215
  let last = null;
206
- let lastActionJson = null;
216
+ const seen = new Map(); // action key -> turns it was taken
207
217
  try {
208
218
  for (let turn = 1; turn <= opts.maxTurns; turn++) {
209
- if (stopRequested)
219
+ if (stopRequested || abortRequested)
210
220
  throw new Interrupted();
211
221
  let user = `# Task\n${opts.task}\n\n# Your notes\n${notes || "(empty)"}\n`;
212
222
  if (last)
@@ -274,8 +284,16 @@ export async function runTask(opts) {
274
284
  emit({ ...ev, parse_error: msg });
275
285
  continue;
276
286
  }
287
+ const notesBefore = notesTok;
277
288
  const n = applyNotes(notes, parsed);
278
289
  notes = n.notes;
290
+ // A rewrite that loses much of the notes is pointed out on the next turn: details dropped in a careless
291
+ // rewrite are the commonest reason an agent redoes work.
292
+ const notesAfter = ntok(notes);
293
+ if (n.ops.includes("rewritten") && notesBefore >= 100 && notesAfter < notesBefore / 2) {
294
+ s.notes_shrink_warnings++;
295
+ n.feedback.push(`note: your notes went from ${notesBefore} to ${notesAfter} tokens with this rewrite; check that nothing you still need was lost (grow the notes with notes_append and notes_edit rather than rewriting them).`);
296
+ }
279
297
  const a = parsed.action;
280
298
  const line = actionLine(a);
281
299
  console.log(prefix + line);
@@ -291,13 +309,16 @@ export async function runTask(opts) {
291
309
  emit(ev);
292
310
  break;
293
311
  }
312
+ // An action the agent already took, at any earlier turn, is pointed out: rereading the same thing in a
313
+ // cycle is the commonest way a memoryless agent gets stuck.
294
314
  const notices = [];
295
- const actionJson = JSON.stringify(a);
296
- if (actionJson === lastActionJson) {
315
+ const key = a.type === "run" ? `run ${String(a.cmd).replace(/\s+/g, " ").trim()}` : JSON.stringify(a);
316
+ const earlier = seen.get(key) ?? [];
317
+ if (earlier.length) {
297
318
  s.repeated_actions++;
298
- notices.push("note: this action is identical to your previous one.");
319
+ notices.push(`note: you already ran this exact ${a.type === "run" ? "command" : "action"} at turn${earlier.length > 1 ? "s" : ""} ${earlier.join(", ")}. If you are rereading, act on what your notes hold, or record what you need from it this time.`);
299
320
  }
300
- lastActionJson = actionJson;
321
+ seen.set(key, [...earlier, turn]);
301
322
  let full;
302
323
  let shown;
303
324
  if (invalid) {
@@ -323,6 +344,9 @@ export async function runTask(opts) {
323
344
  Object.assign(ev, { truncation: r.truncation ?? undefined, exit_code: r.exitCode, signal: r.signal, cmd_seconds: r.seconds, timed_out: r.timedOut,
324
345
  interrupted: r.interrupted });
325
346
  }
347
+ else if (a.type === "read_file") {
348
+ full = shown = sb.readFile(String(a.path), a.start_line, a.end_line);
349
+ }
326
350
  else if (a.type === "write_file") {
327
351
  full = shown = sb.writeFile(String(a.path), String(a.content));
328
352
  }
@@ -340,8 +364,10 @@ export async function runTask(opts) {
340
364
  s.stop_reason = "max_turns";
341
365
  }
342
366
  catch (e) {
367
+ // Ctrl-C at a confirmation prompt arrives as Interrupted without a signal, so anything not SIGTERM or
368
+ // SIGHUP counts as the person's abort.
343
369
  if (e instanceof Interrupted)
344
- s.stop_reason = "interrupted";
370
+ s.stop_reason = stopRequested ? "interrupted" : "aborted";
345
371
  else {
346
372
  s.stop_reason = "error";
347
373
  s.error = e.message;
@@ -380,14 +406,15 @@ export async function runTask(opts) {
380
406
  console.log(`Agent's summary: ${s.self_report_summary}\n`);
381
407
  if (s.self_report_friction)
382
408
  console.log(`Agent's friction report: ${s.self_report_friction}\n`);
383
- if (s.stop_reason === "finish" || s.stop_reason === "max_turns") {
409
+ const evaluable = s.stop_reason === "finish" || s.stop_reason === "max_turns" || s.stop_reason === "aborted";
410
+ if (evaluable) {
384
411
  const ev = await evaluate(runDir, opts.evaluatorModel);
385
412
  if (ev)
386
413
  s.evaluation = { model: ev.model, success: ev.success, reasons: ev.reasons, cost_usd: ev.cost_usd };
387
414
  }
388
415
  console.log(`Run directory: ${runDir}`);
389
416
  if (opts.upload) {
390
- if (s.stop_reason === "finish" || s.stop_reason === "max_turns") {
417
+ if (evaluable) {
391
418
  try {
392
419
  console.log(`Uploaded: ${await uploadRun(runDir, opts.upload)}`);
393
420
  }
package/dist/sandbox.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Where the agent's actions run. A sandbox runs commands with a timeout in their own process group, writes and
2
2
  // edits files, and runs short commands for gentl itself (e.g. to record the final state).
3
3
  import { spawnSync } from "node:child_process";
4
- import { CMD_TIMEOUT_S, countOccurrences, DEFAULT_MAX_CHARS, MAX_MAX_CHARS } from "./protocol.js";
4
+ import { CMD_TIMEOUT_S, countOccurrences, DEFAULT_MAX_CHARS, MAX_MAX_CHARS, READ_MAX_CHARS } from "./protocol.js";
5
5
  const CAPTURE_HEAD = 1 << 20; // bytes of output kept from the start of a command
6
6
  const CAPTURE_TAIL = 1 << 20; // and from the end
7
7
  /** Keep the head and tail, with a note on what was cut and how to see more. */
@@ -76,8 +76,10 @@ export class Sandbox {
76
76
  if (classified.keep)
77
77
  cap.add(Buffer.from(classified.keep));
78
78
  const out = cap.text();
79
- const status = cur.interrupted ? "stopped by the person running gentl (Ctrl-C)"
79
+ let status = cur.interrupted ? "stopped by the person running gentl (Ctrl-C)"
80
80
  : timedOut ? `timed out after ${CMD_TIMEOUT_S} s` : code !== null ? `exit code ${code}` : `killed by ${signal}`;
81
+ if (out.length > maxChars)
82
+ status += `; output cut in the middle (${out.length - Math.floor((maxChars * 3) / 5) - Math.floor((maxChars * 2) / 5)} of ${out.length} chars left out)`;
81
83
  const truncation = out.length > maxChars
82
84
  ? { head: status.length + 1 + Math.floor((maxChars * 3) / 5), tail: Math.floor((maxChars * 2) / 5) } : null;
83
85
  resolve({ exitCode: code, signal, full: `${status}\n${out}`, shown: `${status}\n${truncate(out, maxChars)}`,
@@ -109,17 +111,76 @@ export class Sandbox {
109
111
  const r = this.exec(["sh", "-c", 'mkdir -p "$(dirname "$1")" && cat > "$1"', "sh", p], undefined, content);
110
112
  return r.ok ? `wrote ${p}` : `error: ${r.out.trim()}`;
111
113
  }
114
+ /** Lines `start` to `end` of a file, numbered; never cut in the middle, stopped at a line boundary if too long. */
115
+ readFile(p, start, end) {
116
+ const r = this.exec(["cat", "--", p]);
117
+ if (!r.ok)
118
+ return `error: ${r.out.trim()}`;
119
+ if (r.out.includes("\0"))
120
+ return `error: ${p} is a binary file`;
121
+ const lines = r.out.split("\n");
122
+ if (lines.length && lines[lines.length - 1] === "")
123
+ lines.pop();
124
+ const total = lines.length;
125
+ const from = Math.max(1, start ?? 1);
126
+ const to = Math.min(total, end ?? total);
127
+ if (from > total)
128
+ return `${p} has ${total} lines; nothing at line ${from}`;
129
+ const width = String(to).length;
130
+ let body = "";
131
+ let last = from - 1;
132
+ for (let i = from; i <= to; i++) {
133
+ const line = `${String(i).padStart(width)} ${lines[i - 1]}\n`;
134
+ if (body.length + line.length > READ_MAX_CHARS && i > from)
135
+ break;
136
+ body += line;
137
+ last = i;
138
+ }
139
+ const head = `${p}: lines ${from}-${last} of ${total}`;
140
+ const more = last < to ? `\n[stopped at line ${last} to stay under ${READ_MAX_CHARS} chars; read on with "start_line": ${last + 1}]` : "";
141
+ return `${head}\n${body}${more}`;
142
+ }
112
143
  editFile(p, old, replacement) {
113
144
  const r = this.exec(["cat", "--", p]);
114
145
  if (!r.ok)
115
146
  return `error: ${r.out.trim()}`;
116
147
  const n = countOccurrences(r.out, old);
117
- if (n !== 1)
118
- return `error: the old text occurs ${n} times in ${p}; it must occur exactly once`;
148
+ if (n > 1)
149
+ return `error: the old text occurs ${n} times in ${p}; it must occur exactly once (include more of the surrounding lines)`;
150
+ if (n === 0)
151
+ return `error: the old text occurs 0 times in ${p}; it must occur exactly once${editHint(r.out, old)}`;
119
152
  const w = this.exec(["sh", "-c", 'cat > "$1"', "sh", p], undefined, r.out.replace(old, () => replacement));
120
153
  return w.ok ? `edited ${p}` : `error: ${w.out.trim()}`;
121
154
  }
122
155
  }
156
+ /**
157
+ * Why an edit's old text may not have matched, for the error message: the commonest cause is a backslash
158
+ * sequence in the code (a \\n inside a string) written in JSON as a real line break or tab; otherwise the
159
+ * file's lines that come closest to the old text's first line, so the agent can copy them exactly.
160
+ */
161
+ export function editHint(text, old) {
162
+ const unescaped = old.replace(/\n/g, "\\n").replace(/\t/g, "\\t");
163
+ for (const candidate of [old.replace(/\t/g, "\\t"), unescaped]) {
164
+ if (candidate !== old && countOccurrences(text, candidate) === 1) {
165
+ return ". It would match if a line break or tab inside the old text were the two characters \\n or \\t, as the file has them: in JSON, write a backslash in the code as \\\\ (\\n in the code is \"\\\\n\")";
166
+ }
167
+ }
168
+ const first = old.split("\n").map((l) => l.trim()).find((l) => l.length >= 3);
169
+ if (!first)
170
+ return "";
171
+ const lines = text.split("\n");
172
+ const words = first.split(/\W+/).filter((w) => w.length >= 3);
173
+ const scored = lines.map((l, i) => {
174
+ const t = l.trim();
175
+ const score = t === first ? 1000 : t.includes(first) ? 500 : words.filter((w) => t.includes(w)).length;
176
+ return { i, l, score };
177
+ }).filter((x) => x.score > 0 && (x.score >= 500 || x.score >= Math.max(2, Math.ceil(words.length / 2))));
178
+ scored.sort((a, b) => b.score - a.score || a.i - b.i);
179
+ if (!scored.length)
180
+ return "";
181
+ const shown = scored.slice(0, 3).sort((a, b) => a.i - b.i).map((x) => `${x.i + 1}: ${x.l}`).join("\n");
182
+ return `. The file's lines closest to the old text's first line (copy the old text exactly, including indentation; read_file shows the lines around them):\n${shown}`;
183
+ }
123
184
  /** spawnSync wrapper returning stdout (plus stderr on failure). */
124
185
  export function execLocal(cmd, args, opts) {
125
186
  const p = spawnSync(cmd, args, { ...opts, encoding: "utf8", maxBuffer: 64 << 20 });
package/dist/suite.js CHANGED
@@ -91,8 +91,8 @@ export async function runBatch(name, tasks, repeat, base) {
91
91
  const out = await runTask({ ...base, task: t.task, taskName: repeat > 1 ? `${t.taskName}-r${rep}` : t.taskName,
92
92
  taskSource: t.taskSource, runsDir: batchDir });
93
93
  rows.push(rowFor(t.taskName, rep, batchDir, out.runDir, out.summary));
94
- if (out.summary.stop_reason === "interrupted") {
95
- console.log("\nRun interrupted; skipping the remaining runs.");
94
+ if (out.summary.stop_reason === "interrupted" || out.summary.stop_reason === "aborted") {
95
+ console.log(`\nRun ${out.summary.stop_reason}; skipping the remaining runs.`);
96
96
  stopped = true;
97
97
  }
98
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magland/gentl",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A readability test for codebases: a memoryless agent carries out a newcomer task and gentl measures how much it had to read.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Jeremy Magland",
@@ -32,7 +32,7 @@
32
32
  "node": ">=20"
33
33
  },
34
34
  "scripts": {
35
- "build": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\" && tsc",
35
+ "build": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\" && tsc && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"",
36
36
  "prepare": "npm run build"
37
37
  },
38
38
  "dependencies": {