@magland/gentl 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md CHANGED
@@ -1,3 +1,172 @@
1
- # Temporary Holding Version
1
+ # gentl
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A readability test for codebases. A memoryless agent carries out a realistic task the way a newcomer would (clone the repo, set up the environment from the docs, make a change, verify it), and gentl measures how much the agent had to read. The premise is that if an agent with no memory beyond a small set of notes can do the task, a human newcomer probably can too.
4
+
5
+ gentl is an evaluation tool, not a coding agent; the code the agent writes is a by-product. There are two outputs: the reading effort (a number, mostly useful as a trend over doc changes) and a friction report from an evaluator (what was missing or wrong in the docs, and where the agent guessed).
6
+
7
+ Status: early.
8
+
9
+ ## Install
10
+
11
+ Requires Node >= 20 and Docker (or use `--host`).
12
+
13
+ ```
14
+ npm install -g @magland/gentl
15
+ ```
16
+
17
+ From source: `git clone https://github.com/magland/gentl && cd gentl && npm install && npm link`.
18
+
19
+ For the default backend, set `OPENROUTER_API_KEY` in the environment or in a `.env` file in the directory you run from. To use your Claude Code subscription instead, pass `--backend claude-code` (or set `GENTL_BACKEND=claude-code`); see Backends.
20
+
21
+ ## Usage
22
+
23
+ ```
24
+ gentl run TASK.md
25
+ gentl run -t "Clone https://github.com/owner/foo, install it and run its tests."
26
+ gentl run tasks/ # a suite: every *.md in the directory
27
+ gentl run https://<hub>/tasks/42 --upload # a task from a hub; see Hub
28
+ ```
29
+
30
+ A task is plain text. It names the repos to clone and, where it matters, the tag or commit; the agent does the cloning itself.
31
+
32
+ Options:
33
+
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
+ - `--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.
37
+ - `--confirm`: ask before each action. This is the default with `--host`; Docker runs are unattended unless you pass it.
38
+ - `--yes`: with `--host`, run actions without asking.
39
+ - `--model M`: the agent's model, default `anthropic/claude-haiku-5.5`.
40
+ - `--evaluator-model M`: the evaluator's model, same default.
41
+ - `--upload`: upload each run that finishes, reaches the turn limit or is aborted to the hub its task came from.
42
+ - `--yes`: don't ask before running a hub task (and, with `--host`, before each action).
43
+ - `--backend B`: `openrouter` (default) or `claude-code`; see Backends.
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.
45
+ - `--base-url URL`: any OpenAI-compatible endpoint (default `https://openrouter.ai/api/v1`). The key in `OPENROUTER_API_KEY` is sent to it.
46
+ - `--max-turns N`: default 250.
47
+ - `--repeat N`: run each task N times; see Repeated runs.
48
+ - `--keep`: keep the container or temp workspace instead of deleting it.
49
+
50
+ ### Local copy
51
+
52
+ When the task file (or suite directory, or the current directory for `-t`) is inside a git repo with an `origin` remote, gentl serves clones of that origin from your checkout, including uncommitted changes. So you can edit the docs and rerun without pushing. At startup gentl makes a temporary bare mirror of your checkout; uncommitted changes (tracked and untracked, not ignored) become one extra commit, "Work in progress", under your git identity, on top of your current branch. Your own repo, index and working tree are not touched. The sandbox's git config then redirects the origin URL (in its https, http, `git@host:` and `ssh://` forms, with and without `.git`) to the mirror with `url.<mirror>.insteadOf`, so the agent runs the `git clone` the task names. In Docker the mirror is mounted read-only at `/srv/git`. A suite uses one mirror for all its tasks. gentl prints the local copy at the start, and records it in `summary.json` (`local_copy`); the evaluator is told about it.
53
+
54
+ `--remote` turns this off; `--local` makes it an error if there is no repo to serve. Hub tasks (later) will always clone the published repo.
55
+
56
+ Limits:
57
+
58
+ - Pages the agent reads on GitHub through w3m or curl still show the pushed version, and a task that pins a tag or commit gets that version, not your edits.
59
+ - `git remote -v` in the agent's clone shows the mirror's `file://` URL, so an agent that looks can tell.
60
+ - git's `insteadOf` matches by prefix, so `https://github.com/owner/foo` also catches `https://github.com/owner/foo-extra`. gentl warns when the task text mentions such a URL.
61
+
62
+ ### Docker (default)
63
+
64
+ Each run gets a fresh container (`docker run --init`, working directory `/work`, network enabled), removed at the end. The default image is Debian (trixie) with Python 3.13, Node, build tools, git, curl and w3m; pip works as root without a virtual environment. The agent's commands run with `docker exec` under `setsid`, so a timeout or Ctrl-C kills the whole command inside the container. A custom image needs bash and `setsid` (util-linux), and git for most tasks. No environment variables from your machine are passed into the container.
65
+
66
+ The image and its id are recorded in `summary.json`.
67
+
68
+ ### Host mode
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.
71
+
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
+ ```
79
+
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.
81
+
82
+ ### Suites
83
+
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.
85
+
86
+ ### Repeated runs
87
+
88
+ Reading effort varies a lot from run to run, since the agent may take a different route each time, so a single run says little. `--repeat N` runs each task N times, one after another, into `runs/<timestamp>-<task>-xN/` (or the suite directory). Besides the per-run table, it reports per task the success rate (runs the evaluator judged successful) and the median, interquartile range and range of read tokens, the median turns and the median notes max, all over the successful runs only.
89
+
90
+ Each suite should include a baseline task ("clone, install, run the tests"). Its reading effort is the repo's onboarding cost; read other tasks against it rather than subtracting it.
91
+
92
+ ## How it works
93
+
94
+ Each turn the model sees only the system prompt, the task, its notes, and its last action with the result (shown once). It returns one JSON object:
95
+
96
+ ```
97
+ {"notes_keep": false, "notes": "...", "notes_edit": {"old": "...", "new": "..."}, "notes_append": "...", "action": {...}}
98
+ ```
99
+
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).
103
+
104
+ Actions:
105
+
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.
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.
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.
111
+
112
+ gentl asks for structured output (a JSON schema) so responses parse reliably; if the backend rejects it, gentl falls back to plain text and parses the first JSON object that contains an `action`.
113
+
114
+ ## Backends
115
+
116
+ - `openrouter` (default): any OpenAI-compatible chat completions endpoint, OpenRouter unless `--base-url` says otherwise, with the key in `OPENROUTER_API_KEY`. Each turn is one request with the system prompt and one user message.
117
+ - `claude-code`: your Claude Code login, through a fresh `claude -p` process per call, with no tools, no settings files (so no CLAUDE.md, memory, hooks or plugins), no MCP servers and no saved session. Model ids like `anthropic/claude-haiku-5.5` are mapped to `claude-haiku-5-5`. Claude Code still adds a short preamble and an environment block (cwd, platform, date) to the system prompt, about 740 tokens; `--bare` would remove them but works only with an API key. Effort is Claude Code's default unless `GENTL_CLAUDE_EFFORT` is set. Reported cost is notional. The model call runs on your machine; the sandbox (Docker or host) is unaffected.
118
+
119
+ The backend is recorded in `summary.json`. Runs with different backends are not directly comparable.
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
+
125
+ ## Evaluation
126
+
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:
128
+
129
+ - a verdict: did the agent actually accomplish the task, judged from the evidence rather than the agent's claim;
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.
131
+
132
+ The evaluator reads only the run directory, so a run can be evaluated again later, for example with another model:
133
+
134
+ ```
135
+ gentl evaluate runs/<id> --evaluator-model anthropic/claude-sonnet-5.5
136
+ ```
137
+
138
+ The evaluator's tokens are not counted as reading effort. Reading effort should be compared only among runs that the evaluator judged successful, with the same agent model.
139
+
140
+ ## Hub
141
+
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.
143
+
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.
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.
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.
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.
148
+
149
+ ## Measurement
150
+
151
+ Tokens are counted with tiktoken `o200k_base` on exactly what the agent sees, so counts do not depend on the model.
152
+
153
+ - `read_tokens_total` = `notes_tokens_total` + `result_tokens_total`, summed over turns. This is the headline number.
154
+ - `notes_tokens_max`: the largest the notes got, i.e. the most the agent had to hold at once.
155
+ - `result_tokens_*`: the echoed action plus its result, per turn.
156
+ - `overhead_tokens`: system prompt plus task, counted once.
157
+
158
+ Also recorded: turns, model output tokens, provider-reported tokens and cost, parse errors, invalid, repeated and declined actions, and the questions asked.
159
+
160
+ ## Run directory
161
+
162
+ Each run is written to `./runs/<YYYYMMDD-HHMMSS>-<task name>/`:
163
+
164
+ - `events.jsonl`: a `start` event, one `turn` event per turn (full model input and response, parsed response, notes, full and shown result, where the shown result was cut, token counts, provider usage), and an `end` event.
165
+ - `summary.json`: totals and settings, with a `format` version (currently 1).
166
+ - `transcript.md`: the task, system prompt and each turn as the agent saw it.
167
+ - `final-state.md`: the final state of the workspace (see Evaluation).
168
+ - `evaluation.json` and `friction.md`: the evaluator's verdict and friction report. The verdict is also copied into `summary.json` under `evaluation`.
169
+
170
+ ## License
171
+
172
+ Apache-2.0
package/dist/claude.js ADDED
@@ -0,0 +1,120 @@
1
+ // The claude-code backend: each call is a fresh `claude -p` process using the user's Claude Code login.
2
+ // It runs with no tools, no settings files (so no CLAUDE.md, memory, hooks or plugins), no MCP servers and no saved
3
+ // session. Claude Code still adds a short preamble and an environment block (cwd, platform, date) to the system
4
+ // prompt, about 740 tokens; `--bare` would remove them but only works with an API key, not a subscription login.
5
+ import { spawn } from "node:child_process";
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ import { Interrupted, retryWait, RETRY_DELAYS_S } from "./model.js";
10
+ const CALL_TIMEOUT_S = 600;
11
+ /** OpenRouter-style ids (anthropic/claude-haiku-5.5) to Claude Code ids (claude-haiku-5-5); aliases pass through. */
12
+ export function claudeModelName(model) {
13
+ return model.replace(/^anthropic\//, "").replace(/(\d+)\.(\d+)/g, "$1-$2");
14
+ }
15
+ let scratchDir = null;
16
+ /** An empty directory to run `claude` in when no better cwd is given, removed when gentl exits. */
17
+ function emptyDir() {
18
+ if (!scratchDir) {
19
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gentl-claude-"));
20
+ scratchDir = dir;
21
+ process.on("exit", () => fs.rmSync(dir, { recursive: true, force: true }));
22
+ }
23
+ return scratchDir;
24
+ }
25
+ export class ClaudeCodeClient {
26
+ schema;
27
+ log;
28
+ cwd;
29
+ structured = true;
30
+ model;
31
+ constructor(model, schema, log, cwd) {
32
+ this.schema = schema;
33
+ this.log = log;
34
+ this.cwd = cwd;
35
+ this.model = claudeModelName(model);
36
+ }
37
+ async call(system, user, signal) {
38
+ for (let attempt = 0;; attempt++) {
39
+ const r = await this.once(system, user, signal, this.structured);
40
+ if (r.ok)
41
+ return r.reply;
42
+ // Claude Code gave up producing output that matches the schema. Retrying the same call tends to give a
43
+ // degenerate answer, so retry this one call as plain text (the parser finds the JSON object).
44
+ if (this.structured && /structured_output/i.test(r.error)) {
45
+ this.log(`structured output failed for this call (${r.error}); retrying it as plain text`);
46
+ const plain = await this.once(system, user, signal, false);
47
+ if (plain.ok)
48
+ return plain.reply;
49
+ }
50
+ if (/not logged in|\/login/i.test(r.error))
51
+ throw new Error(`claude: ${r.error} (log in to Claude Code first)`);
52
+ if (this.structured && /schema/i.test(r.error)) {
53
+ this.structured = false;
54
+ this.log(`structured output failed (${r.error.slice(0, 200)}); using plain text from now on`);
55
+ attempt--;
56
+ continue;
57
+ }
58
+ if (attempt >= RETRY_DELAYS_S.length)
59
+ throw new Error(`claude call failed: ${r.error.slice(0, 500)}`);
60
+ await retryWait(attempt, `claude call failed: ${r.error.slice(0, 200)}`, signal, this.log);
61
+ }
62
+ }
63
+ once(system, user, signal, structured) {
64
+ const args = ["-p", "--system-prompt", system, "--tools", "", "--output-format", "json", "--no-session-persistence",
65
+ "--setting-sources", "", "--strict-mcp-config", "--disable-slash-commands", "--model", this.model];
66
+ // Claude Code's default effort unless GENTL_CLAUDE_EFFORT is set (low, medium, high, ...).
67
+ if (process.env.GENTL_CLAUDE_EFFORT)
68
+ args.push("--effort", process.env.GENTL_CLAUDE_EFFORT);
69
+ if (structured)
70
+ args.push("--json-schema", JSON.stringify(this.schema.schema));
71
+ const cwd = this.cwd && fs.existsSync(this.cwd) ? this.cwd : emptyDir();
72
+ return new Promise((resolve, reject) => {
73
+ const child = spawn("claude", args, { cwd, detached: true, stdio: ["pipe", "pipe", "pipe"] });
74
+ let out = "";
75
+ let err = "";
76
+ child.stdout.on("data", (b) => (out += b.toString("utf8")));
77
+ child.stderr.on("data", (b) => (err += b.toString("utf8")));
78
+ const kill = () => {
79
+ try {
80
+ process.kill(-child.pid, "SIGKILL");
81
+ }
82
+ catch {
83
+ // already gone
84
+ }
85
+ };
86
+ const timer = setTimeout(kill, CALL_TIMEOUT_S * 1000);
87
+ const onAbort = () => kill();
88
+ signal.addEventListener("abort", onAbort, { once: true });
89
+ child.on("error", (e) => resolve({ ok: false, error: `could not run claude: ${e.message}` }));
90
+ child.on("close", () => {
91
+ clearTimeout(timer);
92
+ signal.removeEventListener("abort", onAbort);
93
+ if (signal.aborted)
94
+ return reject(new Interrupted());
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(out);
98
+ }
99
+ catch {
100
+ return resolve({ ok: false, error: (err || out || "no output").trim() });
101
+ }
102
+ // The output is a single result object, or (with verbose settings) an array of messages ending in one.
103
+ const r = Array.isArray(parsed) ? parsed.find((m) => m?.type === "result") : parsed;
104
+ if (!r)
105
+ return resolve({ ok: false, error: "no result in claude output" });
106
+ if (r.is_error)
107
+ return resolve({ ok: false, error: String(r.result ?? r.subtype ?? "error") });
108
+ const text = r.structured_output !== undefined ? JSON.stringify(r.structured_output) : String(r.result ?? "");
109
+ const u = r.usage ?? {};
110
+ resolve({ ok: true, reply: { text, usage: {
111
+ prompt_tokens: (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0),
112
+ completion_tokens: u.output_tokens ?? 0,
113
+ cost: r.total_cost_usd ?? 0, // notional: billed to the subscription
114
+ claude: u,
115
+ } } });
116
+ });
117
+ child.stdin.end(user);
118
+ });
119
+ }
120
+ }