AGENTS.md 24 KB

AGENTS.md

This file provides guidance to AI agents when working with code in this repository.

This repo is Amber Smalltalk — an implementation of the Smalltalk language that runs on top of the JS runtime. It is a public open-source project (MIT), primary remote at lolg.it/amber/amber, released to npm as @ambers/lang (with @ambers/sdk and @ambers/cli on their own release tracks). Companion repos (Helios IDE, contrib libraries) are checked out as sibling directories and npm-linked in — see CONTRIBUTING.md for the full clone setup. CLAUDE.md imports this file in full, so every session has all of it.

The file holds three kinds of text, in this order: house rules — how an agent works here, and how code, tests and history are written; conventions of this code — what the next change must look like; and a map of what the repository is. The last section says where a change to each kind goes.

Repository Layout

  • lang/base/ — the hand-written JS kernel (boot, brikz DI, fundamentals / language / runtime layers, parser.js generated from parser.pegjs)
  • lang/src/ — the Smalltalk image: .st sources and their compiled .js counterparts, both committed
  • cli/ — the amber CLI tool (own release track; cli/dist/amber-cli.js is a build product)
  • sdk/ — development kit: grunt tasks, amberc compiler driver, test runner
  • grunt-init-project/ — project scaffolding template
  • index.html, Gruntfile.js, config.js — development files, not part of the release; config.js is generated (grunt devel), never edited by hand
  • ANALYSIS-2026-08.md — measured architecture analysis (boot cost anatomy, sail interpreter evaluation, modernization roadmap); read it before structural work
  • ANALYSIS-2026-09-honest-checkpoint.md — how the stated environment floor was made true (ES2020, Node 12), what each gate needed, and what the checkpoint left for later

House rules

Working here

Modes. A session is in one of two modes, named by a code the user gives. Without a code it is CAGT.

  • CAGT (Coding Agent) — follow existing patterns exactly, act as a velocity enhancer. Do the work asked, nothing more. Suggestions go after, separately.
  • INDR (Independent Reviewer) — free mode: comprehensive review with no constraints on scope. It is there to spot problems and get out of the box, and does whatever it finds useful; it still understands the philosophy and does not try to flip it, though it may try some steering.

Scope. The requested change is the deliverable, neither narrowed nor widened. Code in scope gets no well-intentioned fix and no unsolicited improvement, however small: the codebase is human-reviewed, and the reviewer must not have to separate the change from the tidying. What is noticed on the way is surfaced after the work, separately, in chat. One fix to earlier code is licensed unasked: the preparation the change needs (Preparatory refactoring).

Ask, do not decide, when:

  • a preparatory refactoring is more than trivial — it raises a question, or there is a real choice between ways of clearing the ground;
  • an established spelling looks like a mistake rather than a matter of taste, and no written rule condemns it (Reuse the established way);
  • a change would raise the kernel's syntax level, or break something a 0.x release still carries (Commit message conventions, the Working on X commit).

Everywhere else, make the routine call, say so in the commit message, and go on. The answer to a question asked is written down here, so the next occurrence need not ask.

Report matter-of-factly. Prepare, check, commit, say what happened in a line or two. Verification results belong in the reply; ceremony, repeated hedging and apology do not. A mistake is corrected and reported once.

Git. All local git work is the agent's, none of the remote: never push, pull or fetch — publishing to lolg.it is the user's, and what is there is final (Commits and history). The working tree is shared with the user and with other sessions, whose staged and unstaged work may sit beside yours at any time:

  • stage explicit paths, never git add -A or git add ., and commit with the paths named (git commit <paths>) when anything else is staged; re-read git status --short and git log --oneline -3 immediately before committing, and git show --stat straight after, saying plainly if anything foreign got in;
  • uncommitted changes outside the task are someone else's work in progress: never stage, stash, revert or tidy them;
  • a change is made in the working tree and staged with git add, never spliced in as hashed blobs; an edit keeps the file's line endings.

Before committing, the diff is held against this list:

  • the gates are green (Commands): grunt jshint is clean for the hand-written JS, and npm test passes;
  • no generated file is edited by hand, and what was regenerated is in its own Recompile. commit (Generated artifacts); an API created, deleted or moved is in lang/API-CHANGES.txt under the version being worked on, in the same commit;
  • the diff holds the change and nothing else — no reflowed paragraph, no removed blank line, no reindented untouched code, no foreign file. Compare git show --numstat with git show -w --numstat, and explain or drop every whitespace-only hunk;
  • no comment was added to a method or a statement;
  • every assertion over a collection says whether the order is promised, and pins it only where something orders it;
  • the message is a short sentence with its scope where one applies, a body that says why where the subject cannot, and the Co-Authored-By: trailer.

Code

The rules are XP's: the simplest thing that could possibly work; nothing speculative or "nice to have later"; nothing unused left behind; no premature abstraction. Good naming and human readability are first-class concerns, and code generated by AI meets the same bar as hand-written code.

Code reads as intent, not implementation — it is written for humans, and only incidentally for the machine. Every expression states the claim its author is making, not the mechanics that happen to compute it. In the kernel JS, a guard asking "is there a function yet?" is method.fn == null (the codebase's deliberate eqnull idiom, covering both null and undefined), not !method.fn — the coercion is truthy in exactly the same cases, and says nothing. In Smalltalk, an emptiness claim is aCollection isEmpty, not aCollection size = 0.

Two languages live here, each with its own idiom, and neither is to be dragged toward the other:

  • Smalltalk (.st) follows classic Smalltalk style — small methods, intention-revealing selectors, the collection protocol (collect:, select:, inject:into:) instead of index loops. Kent Beck's Smalltalk Best Practice Patterns is the house reference, literally.
  • Kernel JS (lang/base) is deliberately conservative ES5 syntax (strict mode, var, function) over an asserted ES6 runtime (kernel-checks.js). Match it; do not modernize syntax piecemeal — a syntax-level raise is its own concern, decided deliberately, not smuggled in with a fix.

No comment on a part. A method or a statement carries no comment; a name that seems to need one gets a better name, and in Smalltalk a method comment says what the method is for, not how it does it. The reason for a thing lives in the commit message, in CHANGELOG, or in the analysis documents — those are where a reader is meant to reach.

Reuse the established way. Where the codebase already has a way to do something, the next occurrence uses that way exactly — not a near-twin that reads a little better in isolation. The kernel's eqnull guard is how absence is spelled in JS here, and stays so in every new guard; collect: and select: are how the .st sources iterate. This is the same discipline as not dragging one language toward the other, applied within a language. A spelling is held against the written rules before it is reused, never against taste:

  • one that merely reads worse is reused as it is;
  • one that breaks a rule written here is genuinely wrong: name the rule and change the pattern, at every occurrence, as its own commit, before adding to it;
  • one that looks like a mistake — a bug, a misuse — but breaks no written rule is neither copied nor fixed: ask, and write the answer down either way.

A near-twin costs more than the duplication it looks like: a reader must diff the two to learn that the difference means nothing, and the extraction that would collapse them stops being obvious. Consistency is not tidiness; it is what keeps a pattern refactorable.

Preparatory refactoring. When a requested change lands in code whose current shape would make the diff messy, follow Kent Beck: make the change easy (warning: this may be hard), then make the easy change. The preparation is its own commit ahead of the change, its message saying plainly what it puts right or clears out of the way, and it prepares no more ground than the change needs. Its unit is a pattern, never a site: a deviant site is conformed to the established shape first, and a pattern is changed at every occurrence or not at all; reshaping it at the one site the change lands in is forbidden, however much cleaner the diff would be. Over identical occurrences the preparation is trivial and needs no ask; over nearly identical ones it is always an ask, because making them identical first is the decision. A pattern's extent is its language: what the .st sources settle says nothing about the kernel, and the other way round.

Names. Selectors and functions are named for what they claim, and siblings are named symmetrically, each for what distinguishes it: when a second variant joins a first whose names were general only because it was alone, the first is renamed to what it specifically is everywhere it is spoken, in a preparation commit ahead of the feature, and a name that was specific and now serves both becomes general in the feature commit.

Shape. A value computed once over a variable reassigned; symmetry over ifs, flags and counters. Never "declare an empty collection, fill it in a loop, then use it": a collection-style transformer — .map()/.filter()/.reduce() in JS, collect:/select:/inject:into: in Smalltalk — reads as a data pipeline and leaves no mutable intermediate to misuse.

Tests

Tests are SUnit: test... methods on *Test classes in the *-Tests packages (Kernel-Tests, Compiler-Tests, SUnit-Tests, Platform-DOM-Tests). Method names read as claims — testInvalidAssignment, testDyadicValue — one behaviour per method. A feature or fix lands together with the unit test that pins it (CONTRIBUTING.md says so too; reviews go a lot faster with tests present).

Held to a stricter standard than the code they test, not a looser one. A defect in production is caught by a test; a defect in a test is caught by nobody, and quietly licenses the defect beneath it. "It is only a test" inverts the priority exactly.

Written from the intent, never from the implementation. Test-first is best, because then there is no implementation to consult. A test arriving over code already written imitates that condition: recover the intent — from what the callers do with the result and what a different result would cost them, from the selector's promise, from the commit that introduced it, from what Pharo, the reference implementation, does — state the test from it, and only then read the code to see whether the two agree. A test taken from the code agrees with whatever is there, including whatever is wrong, and breaks when the answer is reworded rather than changed. Where the intent cannot be recovered, the test states what is defensible and the rest is written down rather than asserted.

A test pins what the code promises, caught at the boundary — inputs, outputs, emitted messages, calls leaving the system. What can be read there but was never promised is implementation, and is not asserted: a collection's order unless something orders it, a value that happens to hold today. An assertion states its claim, as any expression does; before one is copied from a neighbouring test, say what it claims. A test that breaks under a refactoring that preserves behaviour is a defect, and is reworked across its pattern before another test copies it.

Three reasons to exist, in Kent Beck's order: a test drives the design, documents the contract, catches a regression. A test written late cannot drive, so the first reason becomes an audit: a contract that is hard to state is a design that is hard to use, and the difficulty surfaces while the test is being written. What the audit finds goes to a suggestion afterwards, never into a silent workaround inside the test, which pins the awkwardness in place as though it were the contract. Where documenting the contract and catching a regression pull apart, the documentation wins: a behaviour true today but never decided is written down, not asserted, because a reader takes every assertion for a promise somebody made.

Read where it stands. The three parts — what is arranged, the one thing done, what is then true — are separated by a blank line and by nothing else, and none of them hides inside a method named for something else. What every case of a kind has in common may go to a helper on the test class; readability outranks factoring, and a helper that exists only to be shared by two other helpers is inlined into both.

Commits and history

History here is curated literature: an authored artifact, written for future readers with the same care as the code, reading as if development had happened in the ideal order — even though it didn't. The final tree is the truth that matters; the sequence of commits is exposition, and exposition is owed to the reader (the argument against the squash school and the record school is at blog.herby.sk/post/commits — a school articulated back in Amber's own active days, though this repo's past was not written under it and is not being rewritten to pretend it was). Two values govern every decision: minimal cognitive load for a reader replaying history, and maximum git blame utility — blame on any line lands on the commit that meaningfully introduced it, with a message explaining why it exists.

  • The unit is one complete concern. A commit carrying half a concern and a commit carrying two are both defects; a secondary concern folded in "on the way" is one too. (CONTRIBUTING.md's "split your fix into small Git commits" is this rule in older words.) Cosmetics — a stray blank line removed, a ragged paragraph reflowed, untouched code reindented — never ride in a content commit; wanted at all, they are a Cosmetic. commit of their own. Text the change displaces is the change's, but only as far as the displacement reaches. Regenerated output is its own Recompile. commit (Generated artifacts).
  • Unpublished history is a draft. Rebase, reword, reorder and fixup freely until it reads right. For contributed work, the PR branch is the draft; it is polished into complete concerns before merge. A flaw found while still drafting is simply fixed in the commit that should have contained it, and a one-off split, fixup or reword keeps the original author and author date.
  • master is untouchable. Amber has external forks, contributors, and a long past that was never written under this school; the moment a commit is on lolg.it master, it is published text, final — never rebased, never rewritten. All curation happens before the push. The past reads as it was written; only the text from here on is authored under these rules.
  • Published flaws are fixed at the tip, in plain words. A flaw found after publishing gets an ordinary commit saying what it fixes and why — Amber's habit since forever (Fix SUnit running., Correct #{} as JavaScriptSource. Fix #1262.). No marker and no ceremony: it is a complete concern like any other, and mentioning the pattern or the commit it corrects is a courtesy to the reader, not a notation to be satisfied. What it must not be is noise — a bare fix typo at the tip earns nothing and costs a line of blame; while the work is still a draft, fold such a thing into the commit it belongs to instead.

Commit message conventions

The notation of the literature — Amber's long-standing habits, plus the school's marks:

  • Subjects are short sentences with a trailing period: Attach context to a javascript exception., Fix SUnit running., Cosmetic.
  • No prefix for lang/ work; component work is scoped: cli:, sdk: / @ambers/sdk:, CI:; a document by its file name: AGENTS.md:, CHANGELOG:
  • Recompile. — a commit containing only regenerated artifacts (Generated artifacts); Recompile (while version set to X) during the release ritual
  • Issue references close the subject: Fixes #1260.
  • The release ritual is a fixed commit sequence: CHANGELOG for X. (or just Release date.) → Release version X → Recompile (while version set to X) → merge tag to stable → Working on X+1 (driven by release.sh / setversion.sh; steps in RELEASING.md)
  • Working on X also opens a breaking change: before the first commit that breaks something 0.x still carried, a commit of that shape bumps the worked-on version to the next minor (lang/package.json, Kernel-Infrastructure.st version, its recompiled .js), so that API-CHANGES.txt entries land under the version that ships them
  • Trailing > — body contains an important standalone addendum
  • Trailing ... — subject is a sentence fragment; body opens with ... completing it
  • Co-Authored-By: <model name> <noreply@anthropic.com> — required trailer on every AI-assisted commit, naming the acting model (e.g. Claude Fable 5) at its maker's domain

Conventions of this code

Generated artifacts — edit the source, regenerate, commit separately

artifact source regenerate with
lang/src/*.js lang/src/*.st grunt amberc:lang / amberc:tests
lang/base/parser.js lang/base/parser.pegjs cd lang && npm run build:parser
config.js package metadata grunt devel
cli/dist/amber-cli.js cli/src + lang npm run build:cli — at release only

Never hand-edit a generated file. The .st file is the source of truth for lang/src; change it (directly or through the IDE, which writes both), then recompile. Regenerated output goes into its own dedicated commit — the long-standing Recompile. convention — so semantic diffs stay readable and blame on .st lines lands on meaning, not on compiler churn. The CLI bundle is the exception: it is the published artifact, rebuilt only by the release ritual's Recompile (while version set to X), and deliberately stale in between — do not commit a rebuild of it during development (building it to verify is fine; discard the result). If you created / deleted / moved API, update lang/API-CHANGES.txt under the version being worked on, in the same commit as the change.

Map

Commands

Setup (npm installs in root, lang/, sdk/; sibling clones for Helios etc.) is in CONTRIBUTING.md. Day-to-day:

grunt                      # full build: parser (pegjs) + amberc:lang + tests build + cli + dev
grunt amberc:lang          # recompile lang/src .st sources to .js
grunt amberc:tests         # recompile the *-Tests packages
grunt devel                # regenerate config.js (amdconfig)
grunt test                 # build test_runner.js and run the SUnit suite (npm run test:sunit)
npm test                   # test:sunit + cli e2e tests
cd lang && npm run build:parser    # regenerate lang/base/parser.js from parser.pegjs
./local-amberc <args>      # amberc against this checkout's lang/ instead of the installed one
grunt jshint               # lint the hand-written JS: kernel, sdk, amberc-cli, Gruntfile, scaffold template

Node: 12 or newer, as every engines field says — the ES2020 floor that lang/base/kernel-checks.js probes for and names on failure. Development happens on current LTS; the build toolchain (grunt) needs a newer Node than Amber itself, so the floor is verified by running the built test runner, not by building on it.

Architecture

The short map (the long, measured one is ANALYSIS-2026-08.md):

  • Boot: lang/base/boot.js wires three brikz layers — fundamentals (selectors, packages, method model) → language (classes, metaclasses, traits) → runtime (method installation, DNU, contexts), the last loaded lazily at initialize(). brikz.js is the micro-DI; cross-layer coupling goes through the emit event table.
  • Package tiers: deploy.js (11 kernel packages) ⊂ devel.js (+ SUnit, tests, DOM) ⊂ lang.js (+ the Compiler-* reflection chain). index.html shows the intended progressive-load choreography.
  • Compiled packages register through $core.addPackage / addClass / addMethod with method factories (instantiateFn); //>>excludeStart("ide"/"ctx"/"imports") pragmas mark strippable metadata, consumed by the r.js optimizer at bundle time.
  • JS interop: detached root classes (String, Number, Array, …) get Amber methods and DNU handlers installed onto the native prototypes; $recv/asReceiver wraps foreign values.
  • lang/base/kernel-interpreter.js is an unwired experimental sketch (the "sail" interpreter) — see the analysis document before touching it.

Eras

  1. Origins (2010–2014) — Nicolas Petton's original Amber; much of the class library and the .st/.js dual-source model dates here.
  2. Team era (2012–2020) — modularization into brikz layers, AMD packaging, traits, the sdk/cli split, lolg.it hosting, the release machinery.
  3. Maintenance era (2020–present) — slow-burn stewardship: the 0.29–0.30 line, globalThis migration, deprecation removals, targeted fixes with tests.

Treat old wiki links and the social-media steps in RELEASING.md as period artifacts; verify before relying on them.

CI / Releases

Three Drone pipelines in .drone.yml on the self-hosted ci.lolg.it (alive; probe it from this machine — tool egress cannot reach it), each gated by a changed-paths check: lang (npm installs + test:sunit on node:lts, then the built test runner executed on node:12, the declared floor), built-cli (build cli/dist + e2e), and installable-cli (install @ambers/cli from npm + e2e against this checkout's lang). Releases follow RELEASING.md: green tests → CHANGELOG / API-CHANGES.txt current → ./release.sh (asks release + next version) → merge the tag into stable. The CHANGELOG entry for the upcoming release is written as the work lands, under a ?? Mon YYYY - Release X header (as 017e724d did for 0.30.0); the ritual fills in the date, or writes the whole entry if nothing did (CHANGELOG for X.).

This file

Three kinds of text, and three homes for a change to it:

  • A map fact is corrected in the commit that makes it false — in that commit's scope, AGENTS.md not added. A change that falsifies many gets an AGENTS.md: commit of its own.
  • A convention is stated in its own AGENTS.md: commit when it is settled, the argument in the body.
  • A house rule is always its own AGENTS.md: commit. A rule given in conversation — a correction, a confirmed way of doing something — is written here in the same session, so that the next agent, and the next person driving one, has it without being told. It enters in the shape of its section: one sentence that states the rule, a clause at most for why, folded into the sentence that already covers the case when there is one — never a paragraph appended where the day's argument happened to end. What jshint or a test can carry goes there instead.