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
10 changes: 5 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ jobs:
# ------------------------------------------------------------- signing --
#
# Gated on the certificate being configured rather than assumed: an
# unsigned build still installs correctly through curl and Homebrew,
# ad-hoc-signed build still installs through curl and Homebrew,
# neither of which quarantines what it downloads. Once the secrets exist
# this stops being optional — there is no continue-on-error below, so a
# signing failure fails the release.
Expand Down Expand Up @@ -245,7 +245,7 @@ jobs:
# workflow but not the ref it ran from — so a dry run would mint proof
# that a build from an arbitrary branch came from this workflow, which is
# indistinguishable from a released one at the only place anybody checks.
# The cost is that a dry run no longer exercises this step; the first real
# The cost is that a dry run does not exercise this step; the first real
# tag does, before any channel points at it.
- uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
if: steps.version.outputs.publish == 'true'
Expand Down Expand Up @@ -421,9 +421,9 @@ jobs:
bash scripts/homebrew-formula.sh dist/release "tula@$VERSION" \
>"tap/Formula/tula@$VERSION.rb"

# Stable lags deliberately: a plain tag promotes, a pre-release never
# does, and a build discovered to be wrong is skipped by promoting the
# next one instead of this one.
# Stable lags deliberately: a plain tag promotes, a pre-release only
# when promote_stable says so, and a build discovered to be wrong is
# skipped by promoting the next one instead of this one.
if [ "$PRERELEASE" = false ] || [ "$PROMOTE" = true ]; then
bash scripts/homebrew-formula.sh dist/release tula >tap/Formula/tula.rb
fi
Expand Down
56 changes: 17 additions & 39 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ src/
types.ts # Connector, KeyScope (tri-state), isOverScoped, unverified, retired venues,
# storedVenues — the one split every count of venues reads off — and Coverage
symbols.ts # one canonical spelling per asset, so one holding is one row
kraken.ts # HMAC over the payload digest; scope partly unprovable
kraken.ts # HMAC over the payload digest; scope from GetApiKeyInfo
binance.ts # HMAC over the query string; scope fully provable
coinbase.ts # CDP keys over JWT (ES256 / EdDSA); scope fully provable
hyperliquid.ts # public address — every account mode, every dex, borrowing, holds, staking, vaults, sub-accounts
Expand Down Expand Up @@ -560,7 +560,6 @@ Two rules, and they are the reason the architecture exists:
modal ones included. Ink's own `kittyKeyboard` detection is not used: under Bun
it handed one reply to the input over and over, and a late one was typed into
the line.
- **Comments say why.** A comment that restates the code is a second copy that drifts.
- **The model's failures are ours to translate.** `explain()` in `src/agent/agent.ts`
turns an API error into a sentence with a next step. A raw `overloaded_error`
envelope printed at somebody asking about their money is not an answer.
Expand Down Expand Up @@ -677,17 +676,9 @@ The agent reads that task for goal and acceptance criteria, the milestone's
published example; if it drifts, every private call fails as
`EAPI:Invalid signature`, which reads as a bad key.
- The caps in `decodeString` (`src/connectors/evm.ts`), `symbol()`
(`src/cli/session.ts`) and `remote()` (`src/core/errors.ts`), and the one
filter every one of them shares,
`visible()` in `src/core/untrusted.ts`. A decoded symbol, a venue's error text
and the model provider's are the strings somebody else writes that are
rendered *and* sent to the model; each is capped and flattened to one line so
none can pose as an instruction. A Hyperliquid builder dex name is the fourth,
held to `DEX_NAME` because a venue label cannot be cut. A fifth has to reach `SECURITY.md` and the
`SOURCES` list in `src/site-claims.test.ts` in the same commit — that list is
what fails the build when a surface names fewer sources than the build has,
and the third got in without it, so two published surfaces disagreed about
how many there were. The filter is one function because it was three
(`src/cli/session.ts`) and `remote()` (`src/core/errors.ts`), `DEX_NAME`, and
the one filter they share, `visible()` in `src/core/untrusted.ts` — rule 4
under Security says why. The filter is one function because it was three
copies that had to agree.
- The tri-state `KeyScope`. Collapsing it to booleans reintroduces the lie. It
is also per-power on purpose: when trading ships, `isOverScoped` drops its
Expand Down Expand Up @@ -957,24 +948,10 @@ bun run build # -> site/out, static
The install path is part of the security product: someone runs it immediately
before pasting keys tied to their net worth.

- **A manual run is a dry run.** `workflow_dispatch` defaults `publish` to
false, because `GITHUB_REF_TYPE` is `branch` there and the tag-matches-version
check cannot protect it — without the gate a manual run would cut a real
release from whatever was on the branch. A pre-release tag (`v0.4.0-rc.1`)
exercises the real channels without touching the stable ones.

**Attestation is gated with the publish steps, not run beside them.** It had
been unconditional, on the reasoning that a dry run should exercise every
step. But an attestation is a public transparency-log entry, and `install.sh`
pins the signing workflow but not the ref it ran from — so a dry run from any
branch minted proof that a build off that branch came from this workflow, which
is indistinguishable from a release at the only place anybody checks. That is
why a dry run publishes nothing at all, and why the input says so.
- **One tag produces every artifact.** `.github/workflows/release.yml` checks the
tag against `src/version.ts`, runs `bun run check`, cross-compiles
darwin/linux × arm64/x64 with Bun, signs the macOS binaries when Apple
credentials are configured, attests every archive, then publishes to GitHub
Releases, npm and the Homebrew tap. Any failing step fails the release.
- **One tag produces every artifact, and a manual run is a dry run that attests
nothing.** `install.sh` pins the signing workflow but not the ref it ran from,
so an attestation minted on a dry run would vouch for any branch.
CONTRIBUTING.md's Releasing section says what `release.yml` checks.
- **Attestation, not a signing key.** GitHub artifact attestations are
sigstore-backed and keyless, so this project has no key to generate, publish,
rotate or lose. `install.sh` verifies one and **refuses** on failure; without
Expand All @@ -989,21 +966,22 @@ before pasting keys tied to their net worth.
rather than reaching the download as a version number and failing as "No build
of latest for <target>" — a working release reading as a broken one. It
resolves what GitHub's own `/releases/latest` and npm's `latest` tag both mean:
the newest release that is not a pre-release. Homebrew spells it `tula` and
`tula-latest`, because `@` there means a pinned version and `tula@latest` would
be a contradiction that installed `keg_only` and reached nobody's PATH.
the newest release that is not a pre-release. Homebrew spells it `tula`, and
the rolling channel `tula-latest`: `@` there means a pinned version, so
`tula@latest` would install `keg_only` and reach nobody's PATH.
- **A version is reachable after the channels move past it, on every channel.**
The installer takes `TULA_VERSION` and keeps each build under
`~/.tula/versions`; npm keeps every version it has published. Homebrew keeps
none by itself — a formula holds exactly one version, so the release job
writes `Formula/tula@<version>.rb` beside the two channel formulae and the tap
accumulates them. Going back is the direction that matters when a build is
showing somebody a wrong number, and it is not worth having on two channels
out of three. The pinned formulae are `keg_only`: they cannot name each other
in a `conflicts_with` because none of the later files exist when one is
rendered, and an old build belongs on PATH only when somebody links it on
purpose. `homebrew-formula.sh` mirrors Homebrew's own `Formulary.class_s`
rather than the two names we happen to ship — `tula@0.1.0` must declare
out of three. The pinned formulae are `keg_only`: an old build belongs on PATH
only when somebody links it on purpose. No formula declares `conflicts_with`:
Homebrew 6+ refuses to load a named formula from a tap the user has not
trusted, and the install line trusts only the formula it names.
`homebrew-formula.sh` mirrors Homebrew's own `Formulary.class_s` rather than
the two names we happen to ship — `tula@0.1.0` must declare
`TulaAT010`, and a class name that disagrees with its file name fails the
whole tap for every user at once.
- **Artifact names are a contract** between `release-build.sh`, the formula, the
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ CI and build plumbing, refactors, and doc-only edits — stays in commit message

## [Unreleased]

### Fixed

- **`brew install hsnice16/tap/tula` works on Homebrew 6 and later.** The formula named `tula-latest` as a conflict, and Homebrew refuses to load a formula from a tap you have not trusted, so the install line failed before downloading anything. To update, name the formula in full — `brew upgrade hsnice16/tap/tula` — since Homebrew refuses the short name for an install it holds no trust for.
- **`TULA_NO_HISTORY` keeps nothing whatever it is set to.** Only `1` turned it off, so `TULA_NO_HISTORY=true` went on saving every line typed.
- **Asking the assistant about an asset matches however the venue spells it.** A position Hyperliquid names `purr` was missed by a question about `PURR`, and a shock the assistant was asked for below -100% now gets the same refusal `/shock` gives.
- **`tula connect` no longer says it never asks for a private key** before asking Kraken and Coinbase users for theirs. Address venues are told an address is all tula needs; keyed venues get the read-only key advice the in-app screen gives.
- **A forced reinstall of a build that does not start leaves the working one in place.** `TULA_FORCE=1` unpacked over the version in use before checking it.
- **More failures say what to do next:** a price source that is rate-limited or down, a Stripe key of the wrong kind, a redirected request, and every `update` hint, which now spells the command for where it is printed.
- **Backspace in an empty field at `tula connect`** no longer erases the prompt.

## [0.3.2] - 2026-09-22

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ These are not style preferences.

1. **A code path that can place an order or move funds.** Including "validate
only" order endpoints. The absence is the product.
2. **A prompt for a seed phrase or private key.** On-chain reads take a public
address.
2. **A prompt for a seed phrase or a wallet's private key.** On-chain reads take
a public address.
3. **Anything that widens access to `src/secrets/store.ts`.** The command layer
and connectors read it; the agent layer never may.
4. **Collapsing an unknown into a default.** `KeyScope.canTrade` is `'unknown'`
Expand Down
8 changes: 2 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ proves that check still catches one.
wording on a connect screen makes it safe to store. Circle Mint was dropped for
exactly this — a Mint key can create payouts and transfers, and Circle publishes
no way to make one that cannot.
- **Credentials stay on your machine**, at `~/.config/tula/credentials.json`,
mode 600 enforced on every read, and are sent only to the venue they belong to.
- **Credentials stay on your machine**, and are sent only to the venue they
belong to.
- **Credentials never enter model context.** The agent layer sees one interface —
the risk engine — and cannot import a connector or the secret store. That is
enforced by `scripts/guard.sh` in CI, not by convention.
Expand Down Expand Up @@ -212,10 +212,6 @@ useful thing you can send.
| Aave V4 | planned — v4 is Hubs and Spokes rather than Pools, so no call the connector makes reaches it ([`breadth/08`](./tasks/breadth/08-aave-v4.md)) |
| Execution | later — see [ROADMAP.md](./ROADMAP.md) |

On a Kraken margin account the positions are read, but the margin level Kraken
would actually liquidate on is not, so those rows rank `unknown` rather than
carrying a distance.

## Keys

<!-- keys:start — generated by `bun run keys:docs` from src/ui/keymap.ts; edit the keymap, not this -->
Expand Down
36 changes: 23 additions & 13 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ need mktemp
need chmod
need ln
need rm
need mv
need cat
need ls
need id
Expand Down Expand Up @@ -295,10 +296,10 @@ check_dir "$BIN_DIR"
check_dir "$VERSION_DIR"

# Every version stays on disk under its own number, so a run asking for one that
# is already there has nothing to fetch. It downloaded and re-verified the whole
# archive regardless — minutes of it, on a link where that is minutes — to arrive
# at the file it already had. The launcher and the PATH line are still put right
# below, because repairing those is the other reason to run this twice.
# is already there has nothing to fetch — re-verifying the archive costs minutes
# on a slow link to arrive at the file it already has. The launcher and the PATH
# line are still put right below, because repairing those is the other reason to
# run this twice.
#
# What makes the short cut safe is that every part of it has to be this script's
# own work: the launcher is a symlink, it points at *this* version directory,
Expand Down Expand Up @@ -340,25 +341,34 @@ if [ -z "$ALREADY" ]; then
note "checking it was built by $REPO"
verify_attestation "$TMP/$ARCHIVE"

mkdir -p "$VERSION_DIR" "$BIN_DIR"
chmod go-w "$INSTALL_DIR" "$VERSION_DIR" "$BIN_DIR" 2>/dev/null || true
tar -xzf "$TMP/$ARCHIVE" -C "$VERSION_DIR" ||
mkdir -p "$INSTALL_DIR/versions" "$BIN_DIR"
chmod go-w "$INSTALL_DIR" "$INSTALL_DIR/versions" "$BIN_DIR" 2>/dev/null || true
# Unpacked beside the version directory, not into it: TULA_FORCE reinstalls
# the version the launcher already runs, and a dead build unpacked over it
# would be running before the check below could refuse it. Same filesystem,
# so the move after the check is a rename; not $TMP, which may be noexec.
STAGE=$(mktemp -d "$INSTALL_DIR/versions/.tula.XXXXXX") ||
die "Could not write to $(tilde "$INSTALL_DIR/versions")." "Check its permissions, then try again."
trap 'rm -rf "$TMP" "$STAGE"' EXIT INT TERM
tar -xzf "$TMP/$ARCHIVE" -C "$STAGE" ||
die "Could not unpack $ARCHIVE." "The download may be truncated; try again."
[ -f "$VERSION_DIR/tula" ] || die "$ARCHIVE did not contain a tula binary." \
[ -f "$STAGE/tula" ] || die "$ARCHIVE did not contain a tula binary." \
"Report it: https://github.com/$REPO/issues"
chmod 755 "$VERSION_DIR/tula"
chmod 755 "$STAGE/tula"
# A checksum and an attestation prove what was built, not that this machine
# will run it — a macOS newer than the build kills a binary that passed both.
# So it runs once before the receipt or the launcher can name it.
note "checking it starts"
# In a subshell that cannot exec it in place, so the shell's own "Killed: 9"
# report lands in the redirect rather than above the message below.
if ! (TULA_NO_UPDATE_CHECK=1 "$VERSION_DIR/tula" --version; exit $?) >/dev/null 2>&1; then
rm -f "$RECEIPT"
if ! (TULA_NO_UPDATE_CHECK=1 "$STAGE/tula" --version; exit $?) >/dev/null 2>&1; then
die "tula $VERSION was downloaded and verified, but does not start on this machine." \
"Your launcher was left as it was. Report it with your OS version:" \
"Nothing was changed. Report it with your OS version:" \
"https://github.com/$REPO/issues — or pin an earlier release with TULA_VERSION."
fi
mkdir -p "$VERSION_DIR"
chmod go-w "$VERSION_DIR" 2>/dev/null || true
mv -f "$STAGE/tula" "$VERSION_DIR/tula"
[ ! -f "$STAGE/LICENSE" ] || mv -f "$STAGE/LICENSE" "$VERSION_DIR/LICENSE"
# What the fast path above compares against on the next run. Written after the
# archive passed its checksum and its attestation, so it records a binary this
# script verified rather than one it merely found.
Expand Down
20 changes: 8 additions & 12 deletions scripts/homebrew-formula.sh
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,16 @@ for t in darwin-arm64 darwin-x64 linux-arm64 linux-x64; do
[ -n "${!var}" ] || die "no checksum for tula-v$VERSION-$t.tar.gz in $RELEASE/checksums.txt"
done

# Two tula binaries on one PATH is a coin toss about which liquidation number you
# are reading, and the two channels and the pinned versions refuse that in the
# two ways Homebrew offers. The channels conflict, because they are alternatives
# and picking one is the point. A pinned version is keg_only instead: it cannot
# name every other pinned version, those files do not exist yet when this one is
# rendered — and keg_only is the stronger answer anyway, since an old build ends
# up on PATH only when somebody links it on purpose.
# A pinned version is keg_only, so an old build reaches PATH only when somebody
# links it on purpose. The two channels declare no conflicts_with: Homebrew 6+
# loads the named formula to check it, and `brew install hsnice16/tap/tula`
# trusts only tula — so the install line refused on an untrusted tula-latest.
# Installing both still fails, at the link step, which names `brew unlink`.
case "$NAME" in
*@*) PATH_RULE=" keg_only :versioned_formula" ;;
*) OTHER=$([ "$NAME" = tula ] && echo tula-latest || echo tula)
PATH_RULE=" conflicts_with \"$OTHER\", because: \"both install a tula binary\"" ;;
*) PATH_RULE="" ;;
esac
PATH_BLOCK=${PATH_RULE:+$PATH_RULE$'\n\n'}

cat <<FORMULA
# Generated by scripts/homebrew-formula.sh. Do not edit by hand.
Expand Down Expand Up @@ -109,9 +107,7 @@ class $CLASS < Formula
end
end

$PATH_RULE

def install
${PATH_BLOCK} def install
bin.install "tula"
end

Expand Down
Loading
Loading