Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@
"runtimeExecutable": ".venv/Scripts/python.exe",
"runtimeArgs": ["-c", "from ui.app import run; run(native=False, port=8123)"],
"port": 8123
},
{
"name": "grad-ui-scratch",
"runtimeExecutable": ".venv/Scripts/python.exe",
"runtimeArgs": [
"-c",
"import os, tempfile, pathlib; root = pathlib.Path(tempfile.gettempdir()) / 'grad-scratch-ws'; root.mkdir(parents=True, exist_ok=True); os.environ['GRAD_ROOT'] = str(root); os.environ['GRAD_APP_DIR'] = str(root / 'appdata'); from ui.app import run; run(native=False, port=8124)"
],
"port": 8124
}
]
}
71 changes: 55 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,16 @@ Then authenticate against the subscription, not the API:
claude setup-token
```

Export the result as `CLAUDE_CODE_OAUTH_TOKEN` and make sure `ANTHROPIC_API_KEY`
is **not** set — it outranks the OAuth token in the credential chain and will
silently bill the Developer Platform instead. `python agent.py --check` removes
it from the process environment and reports what it removed.
Export the result as `CLAUDE_CODE_OAUTH_TOKEN`, **or** store it as
`claude_oauth_token` below and skip the export — the two are equivalent for the
agent's own loop, and the stored copy is the one that works from the desktop
shortcut, which inherits whatever Explorer had and usually that is nothing. An
exported token wins over a stored one, so a terminal that set one deliberately
keeps it. `python agent.py --check` reports which of the two it is using.

Make sure `ANTHROPIC_API_KEY` is **not** set — it outranks the OAuth token in
the credential chain and will silently bill the Developer Platform instead.
`--check` removes it from the process environment and reports what it removed.

Store credentials once; they never enter the agent's environment:

Expand Down Expand Up @@ -219,12 +225,19 @@ directory rather than in `config/grad.toml`: that file is hand-annotated and
reformat it and drop every comment in it. `[kaggle] username` is still read as a
fallback, and `account` says when a stored selection is shadowing one.

Or store them from the app: the workspace menu (`project ▾`) has a credentials
panel, which is the same command with `--stdin` instead of the `getpass` prompt.
That exists because the prompt needs a terminal, and needing one for this was
the only thing that forced a shell open beside the app on a fresh machine. The
value goes down a pipe rather than in an argument — an argv is visible to
anything that can list processes.
Or store them from the app, which is the shorter route: the **setup** window
asks for the subscription token first, then which model runs which role, then
which backends this machine can reach — and it is the same commands underneath,
with `--stdin` instead of the `getpass` prompt. That prompt needs a terminal, and
needing one for this was the only thing that forced a shell open beside the app
on a fresh machine. The value goes down a pipe rather than in an argument — an
argv is visible to anything that can list processes.

A workspace that has never been arranged and has no subscription token opens on
that window, because the four windows it would otherwise open are four windows
that cannot do anything. Nothing else forces it: an unconfigured backend means
no remote training, which is a real limitation and not a reason to put a wizard
in front of someone who opened the app to read a ledger.

## Update

Expand Down Expand Up @@ -687,7 +700,8 @@ that carry the literal next command.
| `tools/kaggle.py` | the same verbs on Kaggle's free GPU/TPU, plus `account` / `quota` / `accelerators` |
| `tools/ledger.py` | `expect` / `query` / `verdict` / `falsify` / `abandon` / `verify` / `reindex` |
| `tools/quota.py` | measured token and credit usage, summarised by stage, role, and project |
| `tools/budget.py` | projects and their ceilings: `new` / `use` / `status` / `raise` / `close` |
| `tools/budget.py` | projects, their ceilings and their own models: `new` / `use` / `status` / `raise` / `configure` / `close` |
| `tools/setup.py` | the writable half of the configuration: `show` / `models` / `backend` / `host` / `check` |
| `tools/docs.py` | is this library call current? introspection first, then Context7 |
| `tools/evolve.py` | evolutionary search as a budgeted campaign, over our own operator |
| `tools/task.py` | run a CLI in the background: `start` / `list` / `status` / `output` / `wait` / `stop` |
Expand Down Expand Up @@ -790,7 +804,8 @@ core/ the machinery the CLIs share, so no tool can forget a rule
traces.py a session as tags a later query can slice on -- pure, tested
submission.py the resolved submission and its hash
gates.py the submit gates and the smoke carve-out
budget.py the project dimension and its three ceilings
budget.py the project dimension, its ceilings and its own models
settings.py the writable overlay: what setup may change, and what it shadows
kaggle_quota.py the weekly accelerator allowance, folded like rolling spend
ledger_store.py event-folded runs, rolling spend, staleness, derived index
submit.py shared submitter machinery: record, collect, deviations
Expand Down Expand Up @@ -895,13 +910,37 @@ unrecognised one still raises rather than returning an empty list.
on that rail spends subscription quota no ledger here can see — which is a
reason not to use it whether or not it works.

- **Phase 2 of the campaign loop (remote evaluation) is enabled, on all three
backends.** The gate was proven locally first, which is what made it safe:
`--remote {ssh|hf_jobs|kaggle} --remote-spec <spec>` evaluates every candidate
on real hardware and refuses unless that spec's preflight is complete and
passing *including the smoke run*. The required checks are named in
`tools/evolve.py` rather than read from `[preflight] checks`, so a machine
configured without `smoke` cannot let a loop with no human in it put forty
candidates on hardware nothing has ever run one step on. A candidate still
never becomes a run — the campaign remains the ledgered unit and its
expectation the bound prediction.

The loop is local; the compute is not. A candidate changes an architecture or
an optimiser, so evaluating one is a training run — which is why each backend
gets a fresh remote per candidate and why every adapter bounds the work *where
it runs* rather than only where it is watched. The three differ in how the
mutated program gets there: `scp` to a host that stays up, a swapped file
inside Kaggle's embedded notebook payload, or a gzipped tar in an environment
variable for HF Jobs, whose pipeline lives in the image and has no upload step.

Kaggle carries a second gate, because the dollar gate cannot see it: that
backend rations *hours*, so a campaign priced at zero would otherwise pass the
budget check and spend the week. `core/kaggle_quota.py` now folds candidate
rows beside runs, closing a hole exactly the size of a campaign.

**Still open:**
- **Historical records are left as `"unassigned"`** rather than retrofitted with
a project. Cheap to change while the ledger is small.
- **Phase 2 of the campaign loop (remote evaluation) is not enabled.**
`--remote` is refused: the gate is proven locally first, because doing the
ledger work and the spend work simultaneously against live GPU jobs is how you
learn about exit 7 the hard way.
- **Nothing has run a remote campaign against live hardware yet.** The gate, the
driver and the records are covered by `tests/test_evolve_remote.py` with the
ssh side stubbed; the first real campaign should be one generation of two
candidates on a host you can watch.

**One correction to HANDOFF-2 itself.** §20 records `repowiki map` as taking
`--format html --open`. The 0.3.1 wheel's `map` takes exactly one `path`,
Expand Down
30 changes: 30 additions & 0 deletions agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,15 +211,30 @@ def preflight_environment() -> dict[str, Any]:
ANTHROPIC_API_KEY outranks CLAUDE_CODE_OAUTH_TOKEN in the credential chain,
so a stray export silently bills the Developer Platform instead of the
subscription. It is removed here rather than warned about.

Then the complement, and the order between the two is the point: the scrub
takes out what must not be there, and `hydrate_environment` supplies the one
thing that must -- the subscription token, from the credential store, for
the app that was launched from a shortcut and never saw an `export`. Both
entry points reach this function (`run_session` and `ui/app.py`'s client
start), which is why the bridge belongs here rather than in either one.
"""
from core import budget # noqa: PLC0415

removed = credentials.scrub_environment()
# Read before hydrating, so the report can distinguish a token someone
# exported from one this just fetched. They authenticate identically; they
# are very different answers to "why is it using that account?".
ambient = bool(os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"))
hydrated = credentials.hydrate_environment()
cfg = config_mod.load()
project_id = budget.current_project()
return {
"removed_env": removed,
"oauth_token_present": bool(os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")),
"oauth_token_source": (
"environment" if ambient else ("credential store" if hydrated else "absent")
),
"workspace": str(paths.root()),
"models": cfg.models(),
# Read from ledger/.current_project, not from the environment -- the
Expand Down Expand Up @@ -1025,6 +1040,11 @@ def main() -> None:
default=None,
help="pin the --ui port; by default 8080, or the next free port above it",
)
parser.add_argument(
"--no-splash",
action="store_true",
help="do not show the loading mark while --ui starts",
)
parser.add_argument("--check", action="store_true", help="report environment and auth posture, then exit")
parser.add_argument(
"--update",
Expand Down Expand Up @@ -1076,6 +1096,16 @@ def main() -> None:
"`python` process holding it and start again."
) from None

# After the instance check and before the first expensive import, which
# is the only window where this is both correct and useful: a second
# launch that handed over above has nothing to load and must flash
# nothing, and everything below this line is the wait being covered.
# `ui/app.py` takes it down when the workspace's first client connects.
if not args.no_splash:
from ui import splash # noqa: PLC0415 - a docstring-only package

splash.start()

from ui.app import run as run_ui # noqa: PLC0415

# A non-default port also moves the app's origin, and the embedded Lab
Expand Down
131 changes: 131 additions & 0 deletions core/budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@
T_PROJECT = "project"
T_PROJECT_RAISED = "project_budget_raised"
T_PROJECT_CLOSED = "project_closed"
#: A project choosing its own models, or the backend it reaches for.
#:
#: An event rather than a field, for the same reason a raise is one: the model a
#: campaign ran under is part of what produced its numbers, and "which model was
#: this project on in March" is a question the ledger should be able to answer
#: rather than one that only the current value can be guessed from. It also
#: means changing a model is recorded beside the ceiling changes, in one
#: append-only file, in order.
T_PROJECT_CONFIGURED = "project_configured"

# Records that predate the dimension fold as this, per §23 item 6: specified as
# left alone rather than retrofitted, and cheap to change while the ledger is
Expand Down Expand Up @@ -160,6 +169,11 @@ def projects() -> dict[str, dict[str, Any]]:
"budget": dict(rec.get("budget") or {}),
"status": rec.get("status", "open"),
"raises": [],
# What this project overrides about how it is run. Empty is the
# common case and means "whatever the workspace says".
"models": {},
"backend": None,
"configured": [],
}
elif pid in folded and kind == T_PROJECT_RAISED:
node = folded[pid]
Expand All @@ -168,6 +182,27 @@ def projects() -> dict[str, dict[str, Any]]:
node["raises"].append(
{"at": rec.get("at"), "budget": rec.get("budget"), "reason": rec.get("reason")}
)
elif pid in folded and kind == T_PROJECT_CONFIGURED:
node = folded[pid]
# `None` clears rather than sets. Folding it as a value would leave a
# role overridden to nothing, which resolves as falsy everywhere and
# is therefore an override that is present, wrong and invisible --
# `settings.clear_models` refuses the same shape for the same reason.
for role, value in (rec.get("models") or {}).items():
if value is None:
node["models"].pop(role, None)
else:
node["models"][role] = str(value)
if "backend" in rec:
node["backend"] = rec["backend"]
node["configured"].append(
{
"at": rec.get("at"),
"models": rec.get("models"),
"backend": rec.get("backend"),
"reason": rec.get("reason"),
}
)
elif pid in folded and kind == T_PROJECT_CLOSED:
folded[pid]["status"] = "closed"
folded[pid]["closed_at"] = rec.get("at")
Expand Down Expand Up @@ -262,6 +297,102 @@ def raise_ceiling(project_id: str, *, budget: dict[str, float], reason: str = ""
return record


def configure(
project_id: str,
*,
models: dict[str, str | None] | None = None,
backend: str | None = None,
reason: str = "",
) -> dict[str, Any]:
"""Record what this project overrides about how it is run.

Appends, like `raise_ceiling`, and for a stronger reason than tidiness: the
model a candidate was mutated by is part of what produced the numbers in the
ledger beside it. A field that could be edited in place would let a project's
history claim it had always been on the model it is on today.

`None` for a role clears it. The vocabulary is checked here rather than
trusted from the caller, because an unknown role stored in the ledger is a
setting nothing will ever read and nothing will ever mention again.
"""
from core import config as config_mod, settings as settings_mod # noqa: PLC0415

project(project_id) # refuses an unknown id before anything is appended
changed = dict(models or {})
unknown = [r for r in changed if r not in config_mod.MODEL_ROLES]
if unknown:
raise UsageError(
f"unknown model role(s): {', '.join(sorted(unknown))}",
fix=f"roles are: {', '.join(config_mod.MODEL_ROLES)}",
)
for role, value in changed.items():
if value is not None and not str(value).strip():
raise UsageError(
f"model for role {role!r} is empty",
fix=f"pass a model id, or clear the override for {role}",
)
if backend is not None and backend not in settings_mod.BACKENDS:
raise UsageError(
f"unknown backend {backend!r}",
fix=f"backends are: {', '.join(settings_mod.BACKENDS)}",
)
if not changed and backend is None:
raise UsageError(
"nothing to configure",
fix="pass a model for a role, or a backend",
)
record: dict[str, Any] = {
"type": T_PROJECT_CONFIGURED,
"id": project_id,
"at": now_iso(),
"models": {r: (None if v is None else str(v).strip()) for r, v in changed.items()},
"reason": reason,
}
if backend is not None:
record["backend"] = backend
jsonl.append(projects_path(), record)
return record


def project_overrides(project_id: str | None) -> dict[str, Any]:
"""What one project overrides, or empty. Never raises.

Read on the config path, so an unknown id, an unreadable ledger or no
selection at all has to mean "nothing overridden" rather than an exception
from inside `config.load()` -- which every surface in the app calls, most of
them while rendering.
"""
if not project_id:
return {"models": {}, "backend": None}
try:
found = projects().get(project_id)
except Exception: # noqa: BLE001 - see the docstring
return {"models": {}, "backend": None}
if not found:
return {"models": {}, "backend": None}
return {
"models": dict(found.get("models") or {}),
"backend": found.get("backend"),
}


def selection_stamp() -> tuple[int, int]:
"""A cheap marker for "the project layer may have changed".

`core/config.py` folds this into its cache key: which project is selected,
and whether the ledger that project's overrides live in has been written.
Two `stat` calls, and they are what make `budget configure` in a child
process visible to a parent that has already loaded a `Config`.
"""
out = []
for path in (current_project_path(), projects_path()):
try:
out.append(path.stat().st_mtime_ns)
except OSError:
out.append(0)
return (out[0], out[1])


def close(project_id: str) -> dict[str, Any]:
project(project_id)
record = {"type": T_PROJECT_CLOSED, "id": project_id, "at": now_iso()}
Expand Down
Loading