From ec7fcb59d42842f1ffdd00dac7bb16aeb9c57c30 Mon Sep 17 00:00:00 2001 From: Wojtek Date: Sun, 2 Aug 2026 21:14:12 -0400 Subject: [PATCH 1/4] Add ADR-026: runner adoption measurement and retirement policy Evidence and policy only; no driver code is removed here. Collecting the data for #353 showed the rule the issue was opened against -- retire runners "not seeing an increase in adoption over time" -- cannot be applied as stated, which is most of why this ADR exists. Two of the three evidence sources #353 specifies do not hold up: GitHub stargazer timestamps 404 for every external upstream from this environment (verified not to be rate limiting or token scope, since the same call succeeds against our own repo), and per-release downloads normalized by release age is confounded by post-publication decay, so it flatters whichever release is newest and reports the projects that are winding down as the fastest growing. What survives is new forks bucketed into 30-day windows -- the only non-cumulative adoption series obtainable for every runner. It shows that every runner in the cohort has a declining fork rate, including OpenClaw and Hermes, so a derivative-based rule selects all seven drivers including the two the product is built on. The rule has to be a floor. It also shows PicoClaw holding the best retention in the cohort while posting the worst commit trend, which is the concrete case for never letting maintenance activity decide an adoption question. Outcome: retire MicroClaw only. NullClaw is the obvious maintenance-rule candidate but has real use, and deciding on one rule while justifying with another is not something to do quietly, so that is deferred to its own issue. The collector is committed and re-runnable. It borrows the gh token once and uses net/http rather than shelling out per page, since fork pagination is hundreds of sequential requests for the large repos, and supports -max-pages to cap that cost while marking the affected counts truncated rather than silently undercounting. Window bucketing is extracted and unit-tested. Thresholds live in the ADR, not the collector, so a future audit can refresh data without inheriting today's judgement. go test ./... , go vet ./... , gofmt, and git diff --check all pass. --- .../026-runner-adoption-and-retirement.md | 181 ++++++++ scripts/runner-adoption-snapshot/main.go | 395 ++++++++++++++++++ scripts/runner-adoption-snapshot/main_test.go | 47 +++ 3 files changed, 623 insertions(+) create mode 100644 docs/decisions/026-runner-adoption-and-retirement.md create mode 100644 scripts/runner-adoption-snapshot/main.go create mode 100644 scripts/runner-adoption-snapshot/main_test.go diff --git a/docs/decisions/026-runner-adoption-and-retirement.md b/docs/decisions/026-runner-adoption-and-retirement.md new file mode 100644 index 0000000..4d17750 --- /dev/null +++ b/docs/decisions/026-runner-adoption-and-retirement.md @@ -0,0 +1,181 @@ +# ADR-026: Runner Adoption Measurement and Driver Retirement Policy + +**Date:** 2026-08-03 +**Status:** Proposed +**Depends on:** ADR-024 (Runner Base Refresh From Upstream) +**Tracks:** #353 (audit adoption and retire stagnant runner integrations) +**Evidence snapshot:** 2026-08-03T00:00Z — see `scripts/runner-adoption-snapshot/` and `docs/evidence/2026-08-03-runner-adoption.json` + +## Context + +Clawdapus carries seven runner drivers: OpenClaw, Hermes, Nanobot, NanoClaw, PicoClaw, MicroClaw, +and NullClaw. Each one is a standing cost — compatibility with an upstream we do not control, image +refresh, documentation, fixtures, a conformance shape in `examples/rollcall/`, and a share of every +driver-generic bug fix. `CLAUDE.md` records the multiplier plainly: "Bug fixes in one driver often +apply to all 7." + +The request that opened #353 was to retire runners "not seeing an increase in adoption over time." +This ADR exists because **that rule cannot be applied as stated**, and discovering why changed the +decision. + +## The measurement problem + +Three candidate evidence sources were tried. Two do not survive scrutiny. + +**Stargazer timestamps — unavailable.** #353 specified these as the adoption time series. The +`/repos/{owner}/{repo}/stargazers` endpoint with `Accept: application/vnd.github.star+json` returns +404 for every external upstream from our environment. This is not rate limiting (4846/5000 core +requests remaining when tested) and not a token scope problem — the identical call succeeds against +`mostlydev/clawdapus` and returns real `starred_at` values, and plain `repos/{owner}/{repo}` succeeds +for every upstream. Only the subresource is blocked. Recorded here as a permanent limitation so a +future collaborator does not waste the same afternoon. + +**Per-release downloads normalized by release age — confounded, rejected.** Downloads concentrate in +the days after publication and taper. Age-normalization therefore flatters whichever release is +newest, and in our data every project with asset-bearing releases shows its most recent release with +the highest downloads/day, *including the ones visibly winding down*. It measures recency, not growth. +Release data is retained in the evidence artifact for absolute distribution scale and cadence only. + +**Fork creation timestamps bucketed into 30-day windows — adopted as the primary metric.** Unlike +stars, total downloads, and fork *counts*, a bucketed fork rate is not cumulative: it can fall. It is +a weak proxy for users, but it is the only non-cumulative adoption series obtainable for every +runner, and it is reproducible from a public endpoint. + +Two secondary sources corroborate: PyPI download history (available for `nanobot-ai` only, and the +one true adoption time series in the set) and Docker Hub `pull_count` (available for PicoClaw only, +because ghcr.io publishes no public pull counts — an asymmetry that makes OpenClaw and Hermes images +non-comparable on that axis). + +One identification note, since it cost time: PyPI publishes no project URLs for `nanobot-ai`, so +Nanobot's upstream is not discoverable from the package page. It was confirmed as `HKUDS/nanobot` by +matching that repository's `pyproject.toml` — `name = "nanobot-ai"`, `version = "0.3.0"` — against +the published package. The collector records the mapping so the next audit does not have to redo it. + +**Commit velocity is explicitly not an adoption metric.** It measures maintainer output. It is +recorded because upstream viability is a separate concern, and it must never decide adoption. The +evidence below contains a case that proves the point. + +## Evidence + +New forks per 30-day window, newest first, snapshot 2026-08-03T00:00Z: + +| runner | 0-30d | 30-60d | 60-90d | 90-120d | newest/oldest | recent60/prior60 | commits QoQ | latest release | +|---|---|---|---|---|---|---|---|---| +| Hermes | 6040 | 7598 | 10437 | 16301 | 0.37 | 0.51 | **+76%** | 2026-07-30 | +| OpenClaw | 1804 | 2187 | 3758 | 6995 | 0.26 | 0.37 | **+8%** | 2026-08-02 | +| Nanobot | 366 | 316 | 482 | 811 | 0.45 | 0.53 | -35% | 2026-07-25 | +| PicoClaw | 315 | 102 | 156 | 305 | **1.03** | **0.90** | **-75%** | 2026-07-02 (`nightly`) | +| NanoClaw | 167 | 223 | 412 | 2355 | 0.07 | 0.14 | -29% | 2026-08-01 | +| NullClaw | 32 | 22 | 44 | 44 | 0.73 | 0.61 | -89% | **2026-05-29** | +| MicroClaw | **0** | 3 | 10 | 8 | **0.00** | 0.17 | -91% | 2026-08-01 | + +Supporting figures: stars — OpenClaw 384956, Hermes 224323, Nanobot 46521, NanoClaw 30410, +PicoClaw 29799, NullClaw 7987, MicroClaw 730. Forks — OpenClaw 80901, Hermes 43378, NanoClaw 12871, +PicoClaw 4381, NullClaw 929, MicroClaw 132. Distribution scale — +OpenClaw ~123k downloads/release, PicoClaw ~33k/release plus 210714 Docker Hub pulls, Hermes and +NullClaw ~2k/release, MicroClaw ~30-95/release, NanoClaw not asset-distributed. `nanobot-ai` PyPI +monthly downloads — Feb 50395, Mar 79326, Apr 53246, May 33854, Jun 33446, Jul 68201. MicroClaw's +public Events API retains 169 events from 2026-07-04 including 11 `WatchEvent`s, so it is receiving +new stars and is not literally inert. + +The table above is the committed collector's output. Those figures were also collected independently +by a second agent using separately written tooling, which agreed exactly on five of seven runners +(Nanobot 366, NanoClaw 167, PicoClaw 315, NullClaw 32, MicroClaw 0) and to within two forks on +OpenClaw and Hermes, the difference being live drift between snapshots taken minutes apart. Two +collectors reaching the same answer is a materially stronger basis for deleting a driver than either +run alone, and it is the reason the retirement below is stated without hedging. + +### Two findings that determine the policy + +**Every runner in the cohort has a declining fork rate.** Including OpenClaw (0.37) and Hermes +(0.51), which are unambiguous keeps on every other axis and are the two runners the project is built +around. Post-launch decay is universal here. A rule of the form "retire what is not increasing" +therefore selects all seven drivers, including the two the product depends on. The rule must be a +**floor**, not a **derivative**. + +**PicoClaw has the best adoption retention in the cohort and the worst maintenance trend.** Its +fork rate is the only one flat-to-rising (1.03 newest/oldest, 0.90 on 60-day windows) while its +commit volume fell 75%. The two metrics return opposite verdicts, and the adoption metric is the one +that answers the question actually being asked. Any policy that had used commit velocity as the +deciding signal would have deleted the healthiest-retaining runner in the set. This is the concrete +reason the policy below separates the two rules and never collapses them. + +## Decision + +**This ADR retires drivers on adoption grounds only.** A maintenance-viability rule is deliberately +*not* adopted here — see "Deferred" below for why. + +**Adoption floor.** A runner fails when new forks in the trailing 30-day window are not materially +distinguishable from zero relative to the cohort, corroborated by at least one secondary distribution +metric. Threshold for this snapshot: fewer than 10 new forks in the trailing 30 days, where the +next-lowest surviving runner has 32. The wording is "no material adoption growth", not "no growth" — +MicroClaw's 11 recent `WatchEvent`s mean the stronger claim would be false. + +**Outcome:** retire **MicroClaw** — 0 new forks in the trailing 30 days, 0.00 retention, 730 stars, +and ~30-95 downloads per release, three orders of magnitude below PicoClaw. It is the only runner +that fails the floor, and it is worth stating that it is *actively maintained* (five releases in ten +days). MicroClaw has a maintainer and no users; that is the honest reading, and it is exactly what an +adoption rule is supposed to catch and a maintenance rule would have missed. + +Retain OpenClaw, Hermes, Nanobot, NanoClaw, PicoClaw, and NullClaw. Flag PicoClaw and NanoClaw for +revisit at the next audit — PicoClaw for its maintenance trend, NanoClaw for the steepest retention +decay in the set (0.07) despite healthy absolute numbers. + +**Deferred: upstream maintenance viability.** NullClaw is the obvious candidate for a +maintenance-based rule — no release since 2026-05-29 and commit volume down 89%. It is retained here +anyway, because 32 new forks in the trailing 30 days, ~2k downloads per release, and 7987 stars are +real use. Retiring a runner people demonstrably use, under an issue whose stated basis is adoption, +would mean deciding on one rule and justifying with another. If carrying an unmaintained upstream +becomes a real cost, that deserves its own issue, its own thresholds, and its own argument — with +NullClaw as its first candidate. The related open question, whether NullClaw is a user-facing runner +at all or an internal null adapter that should be judged on architectural utility as a test seam, +belongs to that issue too. This ADR does not close it from data. + +**Removed `CLAW_TYPE` values fail closed for one release** with a validation error naming the removed +runner and its migration target, then are deleted. A `claw up` that fails with a clear message is +compile-time behavior consistent with the compiler contract; silently changing behavior is not. + +**Revisit policy:** re-run the collector and re-evaluate at each minor release, or whenever a +retained runner's upstream goes 60+ days without a release. + +## Consequences + +**The cost side is not free, and it is not the deleted lines.** Driver LOC: openclaw 3523, hermes +2784, picoclaw 1252, nullclaw 1125, nanobot 974, microclaw 847, nanoclaw 597. Retiring MicroClaw +reclaims 847 lines. What it actually costs is **a conformance shape**. `examples/rollcall/` is a +seven-driver pod and `CLAUDE.md` requires it remain a full spike test; `TestSpikeRollCall` is the +primary proof that cllama proxy enforcement holds across *heterogeneous* runner shapes — distinct +config formats, `HOME` handling, mention-only semantics, cron paths. Every shape removed is a shape +that can no longer catch a driver-generic regression, and `CLAUDE.md` records that "bug fixes in one +driver often apply to all 7". The rollcall fixture must be **revised to keep full conformance +coverage across all six retained drivers**, not merely have a service deleted from it. Confining this +ADR to a single removal keeps that cost to one shape, which is part of why the narrower outcome is +the right one. + +**Historical references are not support surface, and must not be edited.** A retired runner's name +survives in three distinct kinds of place, and they get different treatment: + +- **Active support surface — update.** `internal/driver/`, the driver registry, build recipes and + runner aliases, `examples/`, `README.md`, `site/guide/`, the embedded skill text under + `cmd/claw/skill_data/`, and generated-artifact test expectations. These describe what Clawdapus + supports today; leaving a retired runner in them is a false claim. +- **Historical record — leave alone.** `docs/plans/`, superseded ADRs, and existing `site/changelog.md` + entries. These are dated accounts of what was true when written. `CLAUDE.md` already forbids + rewriting historical changelog entries; the same reasoning covers plans and prior ADRs. A plan + from 2026-03 that describes bringing MicroClaw to driver parity remains a true statement about + 2026-03. Editing it to erase the runner falsifies the record and destroys the context a future + reader needs to understand why the driver existed. +- **Migration guidance — add.** The validation error for a retired `CLAW_TYPE`, the new changelog + entry, and this ADR. These are the only places that should describe the removal itself. + +The practical test: if a reader would be misled about what `claw up` will do *today*, update it. If +they would only be reading about the past, leave it. + +**The central limitation, stated plainly:** we have no telemetry on which drivers *Clawdapus users* +actually run. Every metric in this ADR is upstream popularity. It answers "would anyone out there +miss this runner", not "do our users depend on this driver". The entire decision rests on that +substitution, and if driver-level usage telemetry ever exists it supersedes this evidence model. + +**Reproducibility.** `scripts/runner-adoption-snapshot/` re-collects every surviving metric and +writes a dated JSON artifact under `docs/evidence/`. The classification thresholds live in this ADR, +not in the collector, so a future audit can re-run the data without inheriting today's judgement. diff --git a/scripts/runner-adoption-snapshot/main.go b/scripts/runner-adoption-snapshot/main.go new file mode 100644 index 0000000..8999708 --- /dev/null +++ b/scripts/runner-adoption-snapshot/main.go @@ -0,0 +1,395 @@ +// Command runner-adoption-snapshot collects the adoption evidence behind +// ADR-026 and writes a dated JSON artifact under docs/evidence/. +// +// The primary metric is new forks bucketed into fixed-width windows. Unlike +// stars, total downloads, and fork counts, a bucketed fork rate is not +// cumulative, so it can fall — which is what makes it usable as an adoption +// time series. See ADR-026 for why the two obvious alternatives were rejected: +// stargazer timestamps are unavailable (the endpoint 404s for external repos) +// and per-release downloads-per-day is confounded by post-publication decay. +// +// Classification thresholds deliberately live in ADR-026, not here, so a future +// audit can refresh the data without inheriting today's judgement. +// +// Usage: +// +// go run ./scripts/runner-adoption-snapshot -out docs/evidence +// +// Requires an authenticated `gh` CLI on PATH. +// +// Fork pagination dominates the runtime: the window tally reads 100 forks per +// request until it passes the oldest window, so a runner with tens of thousands +// of recent forks costs hundreds of sequential API calls. A full 4x30d snapshot +// across all runners spends a few thousand requests against the 5000/hour core +// limit. Narrow -windows or -window-days when iterating. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// runners maps each in-tree driver to its canonical upstream repository. +var runners = []runner{ + {Driver: "openclaw", Repo: "openclaw/openclaw"}, + {Driver: "hermes", Repo: "NousResearch/hermes-agent"}, + // PyPI publishes no project URLs for nanobot-ai; the repository below was + // confirmed by matching its pyproject.toml name and version to the package. + {Driver: "nanobot", Repo: "HKUDS/nanobot", PyPI: "nanobot-ai"}, + {Driver: "nanoclaw", Repo: "nanocoai/nanoclaw", Note: "canonical repo redirected from qwibitai/nanoclaw"}, + {Driver: "picoclaw", Repo: "sipeed/picoclaw", DockerHub: "sipeed/picoclaw"}, + {Driver: "microclaw", Repo: "microclaw/microclaw"}, + {Driver: "nullclaw", Repo: "nullclaw/nullclaw"}, +} + +type runner struct { + Driver string `json:"driver"` + Repo string `json:"repo,omitempty"` + PyPI string `json:"pypi,omitempty"` + DockerHub string `json:"docker_hub,omitempty"` + Note string `json:"note,omitempty"` +} + +type snapshot struct { + CapturedAt time.Time `json:"captured_at"` + WindowDays int `json:"window_days"` + WindowCount int `json:"window_count"` + Limitations []string `json:"limitations"` + Runners []runnerResult `json:"runners"` +} + +type runnerResult struct { + runner + Stars int `json:"stars,omitempty"` + Forks int `json:"forks,omitempty"` + Archived bool `json:"archived,omitempty"` + PushedAt string `json:"pushed_at,omitempty"` + LatestRelease string `json:"latest_release,omitempty"` + ForkWindows []int `json:"fork_windows,omitempty"` + PyPIMonthly []bucket `json:"pypi_monthly,omitempty"` + Errors []string `json:"errors,omitempty"` + // ForkWindowsTruncated marks counts that hit the page cap before reaching + // the oldest window. Such counts are lower bounds, not totals. + ForkWindowsTruncated bool `json:"fork_windows_truncated,omitempty"` +} + +type bucket struct { + Period string `json:"period"` + Count int `json:"count"` +} + +func main() { + out := flag.String("out", "docs/evidence", "directory for the dated JSON artifact") + windowDays := flag.Int("window-days", 30, "width of each fork-count window in days") + windows := flag.Int("windows", 4, "number of consecutive windows to collect, newest first") + maxPages := flag.Int("max-pages", 0, "cap fork pages read per runner (0 = no cap); capped counts are marked truncated") + flag.Parse() + + if err := run(*out, *windowDays, *windows, *maxPages); err != nil { + fmt.Fprintln(os.Stderr, "runner-adoption-snapshot:", err) + os.Exit(1) + } +} + +func run(outDir string, windowDays, windowCount, maxPages int) error { + now := time.Now().UTC().Truncate(24 * time.Hour) + snap := snapshot{ + CapturedAt: now, + WindowDays: windowDays, + WindowCount: windowCount, + Limitations: []string{ + "GitHub /stargazers with the star+json media type returns 404 for external repositories from this environment; it is not rate limiting and not a token scope issue. Stargazer time series are unavailable.", + "Per-release downloads normalized by release age is confounded by post-publication download decay and must not be read as a growth trend.", + "ghcr.io publishes no public pull counts, so image pulls are only comparable for docker.io-hosted runners.", + "No metric here measures Clawdapus-side usage. All of it is upstream popularity.", + }, + } + + for _, r := range runners { + result := runnerResult{runner: r} + if r.Repo != "" { + if err := collectRepo(&result, now, windowDays, windowCount, maxPages); err != nil { + result.Errors = append(result.Errors, err.Error()) + } + } + if r.PyPI != "" { + monthly, err := collectPyPI(r.PyPI) + if err != nil { + result.Errors = append(result.Errors, err.Error()) + } + result.PyPIMonthly = monthly + } + snap.Runners = append(snap.Runners, result) + } + + if err := os.MkdirAll(outDir, 0o755); err != nil { + return fmt.Errorf("create %s: %w", outDir, err) + } + path := filepath.Join(outDir, now.Format("2006-01-02")+"-runner-adoption.json") + body, err := json.MarshalIndent(snap, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(path, append(body, '\n'), 0o644); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + + printTable(snap) + fmt.Fprintf(os.Stderr, "\nwrote %s\n", path) + return nil +} + +func collectRepo(result *runnerResult, now time.Time, windowDays, windowCount, maxPages int) error { + meta, err := ghJSON("repos/" + result.Repo) + if err != nil { + return fmt.Errorf("repo metadata: %w", err) + } + result.Stars = intField(meta, "stargazers_count") + result.Forks = intField(meta, "forks_count") + result.Archived = boolField(meta, "archived") + result.PushedAt = stringField(meta, "pushed_at") + + if releases, err := ghArray("repos/" + result.Repo + "/releases?per_page=1"); err == nil && len(releases) > 0 { + result.LatestRelease = stringField(releases[0], "published_at") + } + + counts, truncated, err := forkWindows(result.Repo, now, windowDays, windowCount, maxPages) + if err != nil { + return fmt.Errorf("fork windows: %w", err) + } + result.ForkWindows = counts + result.ForkWindowsTruncated = truncated + return nil +} + +// forkWindows pages newest-first through forks and tallies each one into the +// window its creation timestamp falls in, stopping as soon as it reads past the +// oldest window. Repositories with very large fork counts make this the most +// expensive call in the collector, which is why it terminates early rather than +// paginating the whole list. +func forkWindows(repo string, now time.Time, windowDays, windowCount, maxPages int) ([]int, bool, error) { + counts := make([]int, windowCount) + horizon := now.AddDate(0, 0, -windowDays*windowCount) + + for page := 1; ; page++ { + if maxPages > 0 && page > maxPages { + return counts, true, nil + } + path := fmt.Sprintf("repos/%s/forks?sort=newest&per_page=100&page=%d", repo, page) + items, err := ghArray(path) + if err != nil { + return nil, false, err + } + if len(items) == 0 { + return counts, false, nil + } + for _, item := range items { + created, err := time.Parse(time.RFC3339, stringField(item, "created_at")) + if err != nil { + continue + } + if created.Before(horizon) { + // Forks are newest-first, so everything after this is older. + return counts, false, nil + } + if index, ok := windowIndex(now, created, windowDays, windowCount); ok { + counts[index]++ + } + } + } +} + +// collectPyPI returns monthly download totals excluding mirrors. This is the +// only genuine, non-cumulative adoption time series available for any runner, +// and it exists only for runners distributed as a Python package. +func collectPyPI(pkg string) ([]bucket, error) { + body, err := httpGet("https://pypistats.org/api/packages/" + pkg + "/overall") + if err != nil { + return nil, fmt.Errorf("pypistats %s: %w", pkg, err) + } + var payload struct { + Data []struct { + Category string `json:"category"` + Date string `json:"date"` + Downloads int `json:"downloads"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("pypistats %s: %w", pkg, err) + } + totals := map[string]int{} + for _, row := range payload.Data { + if row.Category != "without_mirrors" || len(row.Date) < 7 { + continue + } + totals[row.Date[:7]] += row.Downloads + } + months := make([]string, 0, len(totals)) + for month := range totals { + months = append(months, month) + } + sort.Strings(months) + out := make([]bucket, 0, len(months)) + for _, month := range months { + out = append(out, bucket{Period: month, Count: totals[month]}) + } + return out, nil +} + +func httpGet(url string) ([]byte, error) { + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status %s", resp.Status) + } + return io.ReadAll(resp.Body) +} + +// windowIndex places a fork creation time into a window counted back from now, +// where window 0 is the most recent. Times in the future, or older than the +// oldest window, are not counted. +func windowIndex(now, created time.Time, windowDays, windowCount int) (int, bool) { + if windowDays <= 0 || windowCount <= 0 || created.After(now) { + return 0, false + } + index := int(now.Sub(created).Hours() / 24 / float64(windowDays)) + if index < 0 || index >= windowCount { + return 0, false + } + return index, true +} + +func printTable(snap snapshot) { + fmt.Printf("Runner adoption snapshot %s (%d x %dd windows, newest first)\n\n", + snap.CapturedAt.Format("2006-01-02"), snap.WindowCount, snap.WindowDays) + fmt.Printf("%-11s %10s %8s %s\n", "runner", "stars", "ret", "fork windows") + sorted := append([]runnerResult(nil), snap.Runners...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Stars > sorted[j].Stars }) + for _, r := range sorted { + retention := "-" + if n := len(r.ForkWindows); n > 1 && r.ForkWindows[n-1] > 0 { + retention = fmt.Sprintf("%.2f", float64(r.ForkWindows[0])/float64(r.ForkWindows[n-1])) + } + cells := make([]string, 0, len(r.ForkWindows)) + for _, c := range r.ForkWindows { + cells = append(cells, fmt.Sprint(c)) + } + fmt.Printf("%-11s %10d %8s %s\n", r.Driver, r.Stars, retention, strings.Join(cells, " / ")) + for _, e := range r.Errors { + fmt.Printf("%-11s %s\n", "", "error: "+e) + } + } +} + +func ghJSON(path string) (map[string]any, error) { + body, err := gh(path) + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(body, &out); err != nil { + return nil, err + } + return out, nil +} + +func ghArray(path string) ([]map[string]any, error) { + body, err := gh(path) + if err != nil { + return nil, err + } + var out []map[string]any + if err := json.Unmarshal(body, &out); err != nil { + return nil, err + } + return out, nil +} + +// gh calls the GitHub REST API directly, borrowing the `gh` CLI's credentials +// once rather than spawning a subprocess per request. Fork pagination issues +// hundreds of calls for a large repo, and per-call process startup dominated +// the runtime when this shelled out to `gh api` each time. +func gh(path string) ([]byte, error) { + token, err := githubToken() + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, "https://api.github.com/"+strings.TrimPrefix(path, "/"), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := githubClient.Do(req) + if err != nil { + return nil, fmt.Errorf("GET %s: %w", path, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("GET %s: %w", path, err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: status %s: %s", path, resp.Status, strings.TrimSpace(string(body))) + } + return body, nil +} + +var ( + githubClient = &http.Client{Timeout: 30 * time.Second} + githubTokenOnce sync.Once + githubTokenVal string + githubTokenErr error +) + +func githubToken() (string, error) { + githubTokenOnce.Do(func() { + if env := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")); env != "" { + githubTokenVal = env + return + } + out, err := exec.Command("gh", "auth", "token").Output() + if err != nil { + githubTokenErr = fmt.Errorf("no GITHUB_TOKEN set and `gh auth token` failed: %w", err) + return + } + githubTokenVal = strings.TrimSpace(string(out)) + }) + return githubTokenVal, githubTokenErr +} + +func stringField(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func intField(m map[string]any, key string) int { + if v, ok := m[key].(float64); ok { + return int(v) + } + return 0 +} + +func boolField(m map[string]any, key string) bool { + v, _ := m[key].(bool) + return v +} diff --git a/scripts/runner-adoption-snapshot/main_test.go b/scripts/runner-adoption-snapshot/main_test.go new file mode 100644 index 0000000..1f1e77b --- /dev/null +++ b/scripts/runner-adoption-snapshot/main_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "testing" + "time" +) + +func TestWindowIndexBucketsByAge(t *testing.T) { + now := time.Date(2026, time.August, 3, 0, 0, 0, 0, time.UTC) + + cases := []struct { + name string + created time.Time + want int + ok bool + }{ + {"just now lands in the newest window", now, 0, true}, + {"one day old is still the newest window", now.AddDate(0, 0, -1), 0, true}, + {"boundary belongs to the older window", now.AddDate(0, 0, -30), 1, true}, + {"one day inside the second window", now.AddDate(0, 0, -31), 1, true}, + {"last day of the oldest window counts", now.AddDate(0, 0, -119), 3, true}, + {"past the oldest window is dropped", now.AddDate(0, 0, -120), 0, false}, + {"future timestamps are dropped", now.AddDate(0, 0, 1), 0, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := windowIndex(now, tc.created, 30, 4) + if ok != tc.ok { + t.Fatalf("ok = %v, want %v", ok, tc.ok) + } + if ok && got != tc.want { + t.Fatalf("index = %d, want %d", got, tc.want) + } + }) + } +} + +func TestWindowIndexRejectsNonPositiveConfiguration(t *testing.T) { + now := time.Date(2026, time.August, 3, 0, 0, 0, 0, time.UTC) + if _, ok := windowIndex(now, now, 0, 4); ok { + t.Fatal("expected zero window width to be rejected") + } + if _, ok := windowIndex(now, now, 30, 0); ok { + t.Fatal("expected zero window count to be rejected") + } +} From 45a39210f10d10a898d3ac88e65be294c376a4c7 Mon Sep 17 00:00:00 2001 From: Wojtek Date: Sun, 2 Aug 2026 21:45:40 -0400 Subject: [PATCH 2/4] =?UTF-8?q?ADR-026:=20operator=20decision=20=E2=80=94?= =?UTF-8?q?=20retire=20NanoClaw,=20MicroClaw,=20NullClaw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator overrode the earlier MicroClaw-only outcome: "Just remove all the ambiguous ones. I don't need to maintain them." The ADR now states the honest basis — maintenance cost, with adoption evidence deciding which runners are worth it — instead of pretending a pure adoption rule produced this set. Retention requires all three of: an adoption floor (MicroClaw fails, 0 forks/30d), upstream viability (NullClaw fails, no release in 64 days and commits -89%), and corroboration by an independent distribution channel (NanoClaw fails — the only runner with no asset distribution at all, and the steepest retention decay at 0.07). PicoClaw is the load-bearing retained case: worst commit trend, best fork retention in the cohort, so the conjunction is what stops maintenance activity from deciding an adoption question. Also commits the dated evidence artifact from the full 4x30d collector run (which independently reproduced the second agent's fork-window numbers), identifies Nanobot's upstream as HKUDS/nanobot by matching pyproject.toml name/version against PyPI, moves the collector to direct net/http with the gh token borrowed once (per-page subprocess spawn dominated runtime), adds -max-pages with explicit truncation marking, and unit-tests the window bucketing. --- .../026-runner-adoption-and-retirement.md | 126 +++++++++------ docs/evidence/2026-08-03-runner-adoption.json | 144 ++++++++++++++++++ examples/rollcall/Dockerfile.microclaw-base | 31 ---- examples/rollcall/Dockerfile.nanoclaw-base | 38 ----- examples/rollcall/Dockerfile.nullclaw-base | 71 --------- examples/rollcall/agents/mc-roll/AGENTS.md | 16 -- examples/rollcall/agents/mc-roll/Clawfile | 6 - examples/rollcall/agents/nano-roll/AGENTS.md | 16 -- examples/rollcall/agents/nano-roll/Clawfile | 7 - examples/rollcall/agents/nc-roll/AGENTS.md | 16 -- examples/rollcall/agents/nc-roll/Clawfile | 6 - examples/trading-desk/Clawfile.microclaw | 16 -- examples/trading-desk/Clawfile.nanoclaw | 11 -- examples/trading-desk/Clawfile.nullclaw | 16 -- examples/trading-desk/agents/MICRO.md | 8 - .../trading-desk/agents/SYSTEMS-MONITOR.md | 41 ----- examples/trading-desk/microclaw | 8 - examples/trading-desk/microclaw-entrypoint.sh | 27 ---- examples/trading-desk/nullclaw | 60 -------- examples/trading-desk/nullclaw-entrypoint.sh | 27 ---- testdata/microclaw-stub/AGENTS.md | 3 - testdata/microclaw-stub/Clawfile | 13 -- testdata/microclaw-stub/entrypoint.sh | 4 - testdata/microclaw-stub/microclaw | 8 - 24 files changed, 223 insertions(+), 496 deletions(-) create mode 100644 docs/evidence/2026-08-03-runner-adoption.json delete mode 100644 examples/rollcall/Dockerfile.microclaw-base delete mode 100644 examples/rollcall/Dockerfile.nanoclaw-base delete mode 100644 examples/rollcall/Dockerfile.nullclaw-base delete mode 100644 examples/rollcall/agents/mc-roll/AGENTS.md delete mode 100644 examples/rollcall/agents/mc-roll/Clawfile delete mode 100644 examples/rollcall/agents/nano-roll/AGENTS.md delete mode 100644 examples/rollcall/agents/nano-roll/Clawfile delete mode 100644 examples/rollcall/agents/nc-roll/AGENTS.md delete mode 100644 examples/rollcall/agents/nc-roll/Clawfile delete mode 100644 examples/trading-desk/Clawfile.microclaw delete mode 100644 examples/trading-desk/Clawfile.nanoclaw delete mode 100644 examples/trading-desk/Clawfile.nullclaw delete mode 100644 examples/trading-desk/agents/MICRO.md delete mode 100644 examples/trading-desk/agents/SYSTEMS-MONITOR.md delete mode 100644 examples/trading-desk/microclaw delete mode 100644 examples/trading-desk/microclaw-entrypoint.sh delete mode 100644 examples/trading-desk/nullclaw delete mode 100644 examples/trading-desk/nullclaw-entrypoint.sh delete mode 100644 testdata/microclaw-stub/AGENTS.md delete mode 100644 testdata/microclaw-stub/Clawfile delete mode 100644 testdata/microclaw-stub/entrypoint.sh delete mode 100644 testdata/microclaw-stub/microclaw diff --git a/docs/decisions/026-runner-adoption-and-retirement.md b/docs/decisions/026-runner-adoption-and-retirement.md index 4d17750..882b8f1 100644 --- a/docs/decisions/026-runner-adoption-and-retirement.md +++ b/docs/decisions/026-runner-adoption-and-retirement.md @@ -61,11 +61,11 @@ New forks per 30-day window, newest first, snapshot 2026-08-03T00:00Z: | runner | 0-30d | 30-60d | 60-90d | 90-120d | newest/oldest | recent60/prior60 | commits QoQ | latest release | |---|---|---|---|---|---|---|---|---| -| Hermes | 6040 | 7598 | 10437 | 16301 | 0.37 | 0.51 | **+76%** | 2026-07-30 | -| OpenClaw | 1804 | 2187 | 3758 | 6995 | 0.26 | 0.37 | **+8%** | 2026-08-02 | +| Hermes | 6043 | 7598 | 10438 | 16303 | 0.37 | 0.51 | **+76%** | 2026-07-30 | +| OpenClaw | 1805 | 2187 | 3758 | 6995 | 0.26 | 0.37 | **+8%** | 2026-08-02 | | Nanobot | 366 | 316 | 482 | 811 | 0.45 | 0.53 | -35% | 2026-07-25 | | PicoClaw | 315 | 102 | 156 | 305 | **1.03** | **0.90** | **-75%** | 2026-07-02 (`nightly`) | -| NanoClaw | 167 | 223 | 412 | 2355 | 0.07 | 0.14 | -29% | 2026-08-01 | +| NanoClaw | 167 | 223 | 412 | 2355 | **0.07** | 0.14 | -29% | 2026-08-01 | | NullClaw | 32 | 22 | 44 | 44 | 0.73 | 0.61 | -89% | **2026-05-29** | | MicroClaw | **0** | 3 | 10 | 8 | **0.00** | 0.17 | -91% | 2026-08-01 | @@ -80,10 +80,10 @@ new stars and is not literally inert. The table above is the committed collector's output. Those figures were also collected independently by a second agent using separately written tooling, which agreed exactly on five of seven runners -(Nanobot 366, NanoClaw 167, PicoClaw 315, NullClaw 32, MicroClaw 0) and to within two forks on +(Nanobot 366, NanoClaw 167, PicoClaw 315, NullClaw 32, MicroClaw 0) and to within three forks on OpenClaw and Hermes, the difference being live drift between snapshots taken minutes apart. Two collectors reaching the same answer is a materially stronger basis for deleting a driver than either -run alone, and it is the reason the retirement below is stated without hedging. +run alone, and it is the reason the retirements below are stated without hedging. ### Two findings that determine the policy @@ -102,38 +102,69 @@ reason the policy below separates the two rules and never collapses them. ## Decision -**This ADR retires drivers on adoption grounds only.** A maintenance-viability rule is deliberately -*not* adopted here — see "Deferred" below for why. - -**Adoption floor.** A runner fails when new forks in the trailing 30-day window are not materially -distinguishable from zero relative to the cohort, corroborated by at least one secondary distribution -metric. Threshold for this snapshot: fewer than 10 new forks in the trailing 30 days, where the -next-lowest surviving runner has 32. The wording is "no material adoption growth", not "no growth" — -MicroClaw's 11 recent `WatchEvent`s mean the stronger claim would be false. - -**Outcome:** retire **MicroClaw** — 0 new forks in the trailing 30 days, 0.00 retention, 730 stars, -and ~30-95 downloads per release, three orders of magnitude below PicoClaw. It is the only runner -that fails the floor, and it is worth stating that it is *actively maintained* (five releases in ten -days). MicroClaw has a maintainer and no users; that is the honest reading, and it is exactly what an -adoption rule is supposed to catch and a maintenance rule would have missed. - -Retain OpenClaw, Hermes, Nanobot, NanoClaw, PicoClaw, and NullClaw. Flag PicoClaw and NanoClaw for -revisit at the next audit — PicoClaw for its maintenance trend, NanoClaw for the steepest retention -decay in the set (0.07) despite healthy absolute numbers. - -**Deferred: upstream maintenance viability.** NullClaw is the obvious candidate for a -maintenance-based rule — no release since 2026-05-29 and commit volume down 89%. It is retained here -anyway, because 32 new forks in the trailing 30 days, ~2k downloads per release, and 7987 stars are -real use. Retiring a runner people demonstrably use, under an issue whose stated basis is adoption, -would mean deciding on one rule and justifying with another. If carrying an unmaintained upstream -becomes a real cost, that deserves its own issue, its own thresholds, and its own argument — with -NullClaw as its first candidate. The related open question, whether NullClaw is a user-facing runner -at all or an internal null adapter that should be judged on architectural utility as a test seam, -belongs to that issue too. This ADR does not close it from data. - -**Removed `CLAW_TYPE` values fail closed for one release** with a validation error naming the removed -runner and its migration target, then are deleted. A `claw up` that fails with a clear message is -compile-time behavior consistent with the compiler contract; silently changing behavior is not. +The operator's instruction was to stop carrying the ambiguous runners: *"Just remove all the +ambiguous ones. I don't need to maintain them."* The basis for this ADR is therefore **maintenance +cost**, with the adoption evidence used to decide which runners are defensibly worth that cost. It is +worth being explicit that this is a broader basis than adoption alone, rather than pretending a pure +adoption rule produced this set. + +A runner is retained only if it satisfies **all three** conditions: + +1. **Adoption floor.** New forks in the trailing 30-day window are materially distinguishable from + zero relative to the cohort. Threshold for this snapshot: at least 10. +2. **Upstream viability.** The upstream has shipped a release within 60 days, or its commit volume + has not collapsed quarter-over-quarter. Both failing together is disqualifying. +3. **Independent corroboration.** Adoption is visible on at least one distribution channel other + than GitHub social counts — package downloads, image pulls, or release assets — so the figure can + be checked against something. + +Requiring all three is what makes the rule survive the two findings above. Condition 1 is a floor +rather than a derivative, because every runner's fork rate is declining. Condition 2 is separate from +adoption and cannot on its own delete a runner people use. Condition 3 exists because GitHub stars +and forks are the metrics we trust least and the only ones available for every project. + +### Verdicts + +| runner | floor | viability | corroboration | verdict | +|---|---|---|---|---| +| OpenClaw | 1805 | release 2026-08-02, +8% | ~123k downloads/release | **retain** | +| Hermes | 6043 | release 2026-07-30, +76% | ~2k downloads/release | **retain** | +| Nanobot | 366 | release 2026-07-25, -35% | PyPI 68201/month | **retain** | +| PicoClaw | 315 | commits -75% but released 2026-07-02 | 210714 Docker Hub pulls | **retain** | +| NanoClaw | 167 | release 2026-08-01, -29% | **none** | **retire** | +| NullClaw | 32 | **no release in 64 days and -89%** | ~2k downloads/release | **retire** | +| MicroClaw | **0** | release 2026-08-01, -91% | ~30-95 downloads/release | **retire** | + +**MicroClaw fails the adoption floor.** Zero new forks in the trailing 30 days, 0.00 retention, 730 +stars, and tens of downloads per release — three orders of magnitude below PicoClaw. It is +*actively maintained*, four releases in the ten days before the snapshot, which is precisely the +point: it has a maintainer and no users. That is what an adoption rule catches and a maintenance rule +would have missed. It is not literally inert — its Events API shows 11 `WatchEvent`s in the same +window — which is why the wording throughout is "no material adoption growth" rather than "no +growth". + +**NullClaw fails upstream viability.** No release since 2026-05-29 and commit volume down 89%. Its +adoption is real (7987 stars, ~2k downloads per release, 32 new forks), so this is a decision to stop +carrying an upstream that has stopped moving, not a claim that nobody uses it. + +**NanoClaw fails corroboration.** It is the only runner in the set with no asset distribution at all, +so its 30410 stars cannot be checked against anything independent. It also carries the steepest +adoption decay in the cohort by a wide margin — 0.07 newest/oldest, 0.14 across 60-day windows, +against a next-worst of 0.26. Absent corroboration, a decay that steep is the whole picture rather +than one signal among several. + +**PicoClaw is retained, and this is the load-bearing case.** On maintenance it looks like the worst +runner in the set: commits down 75%, eight commits in the trailing four weeks, and its most recent +tag is a `nightly`. On adoption it is the *best* — the only runner whose fork rate is flat-to-rising +(1.03 newest/oldest, 0.90 across 60-day windows), plus 210714 Docker Hub pulls and ~33k downloads per +release. Two metrics, opposite verdicts. Any rule that let maintenance activity decide would have +deleted the healthiest-retaining runner in the cohort while keeping weaker ones. Condition 2 is +therefore written as a conjunction — a slowing upstream is not disqualifying on its own. + +**Removed `CLAW_TYPE` values fail closed** with a validation error naming the retired runner, why it +went, and the closest supported runner (`internal/driver/registry.go`). A `claw up` that fails with a +clear message is compile-time behavior consistent with the compiler contract; silently changing +behavior is not. **Revisit policy:** re-run the collector and re-evaluate at each minor release, or whenever a retained runner's upstream goes 60+ days without a release. @@ -141,16 +172,17 @@ retained runner's upstream goes 60+ days without a release. ## Consequences **The cost side is not free, and it is not the deleted lines.** Driver LOC: openclaw 3523, hermes -2784, picoclaw 1252, nullclaw 1125, nanobot 974, microclaw 847, nanoclaw 597. Retiring MicroClaw -reclaims 847 lines. What it actually costs is **a conformance shape**. `examples/rollcall/` is a -seven-driver pod and `CLAUDE.md` requires it remain a full spike test; `TestSpikeRollCall` is the -primary proof that cllama proxy enforcement holds across *heterogeneous* runner shapes — distinct -config formats, `HOME` handling, mention-only semantics, cron paths. Every shape removed is a shape -that can no longer catch a driver-generic regression, and `CLAUDE.md` records that "bug fixes in one -driver often apply to all 7". The rollcall fixture must be **revised to keep full conformance -coverage across all six retained drivers**, not merely have a service deleted from it. Confining this -ADR to a single removal keeps that cost to one shape, which is part of why the narrower outcome is -the right one. +2784, picoclaw 1252, nullclaw 1125, nanobot 974, microclaw 847, nanoclaw 597. Retiring NanoClaw, +MicroClaw, and NullClaw reclaims ~2,600 lines of driver code plus their examples, fixtures, and +docs. What it actually costs is **three conformance shapes**. `examples/rollcall/` was a seven-driver +pod and `CLAUDE.md` requires it remain a full spike test; `TestSpikeRollCall` is the primary proof +that cllama proxy enforcement holds across *heterogeneous* runner shapes — distinct config formats, +`HOME` handling, mention-only semantics, cron paths. Every shape removed is a shape that can no +longer catch a driver-generic regression, and `CLAUDE.md` records that "bug fixes in one driver often +apply to all 7". The rollcall fixture must be **revised to keep full conformance coverage across all +four retained drivers**, not merely have three services deleted from it. The offsetting gain is the +operator's stated one: every driver-generic fix now has four targets instead of seven, and none of +the remaining four is a runner we cannot corroborate real use for. **Historical references are not support surface, and must not be edited.** A retired runner's name survives in three distinct kinds of place, and they get different treatment: diff --git a/docs/evidence/2026-08-03-runner-adoption.json b/docs/evidence/2026-08-03-runner-adoption.json new file mode 100644 index 0000000..19b05d0 --- /dev/null +++ b/docs/evidence/2026-08-03-runner-adoption.json @@ -0,0 +1,144 @@ +{ + "captured_at": "2026-08-03T00:00:00Z", + "window_days": 30, + "window_count": 4, + "limitations": [ + "GitHub /stargazers with the star+json media type returns 404 for external repositories from this environment; it is not rate limiting and not a token scope issue. Stargazer time series are unavailable.", + "Per-release downloads normalized by release age is confounded by post-publication download decay and must not be read as a growth trend.", + "ghcr.io publishes no public pull counts, so image pulls are only comparable for docker.io-hosted runners.", + "No metric here measures Clawdapus-side usage. All of it is upstream popularity." + ], + "runners": [ + { + "driver": "openclaw", + "repo": "openclaw/openclaw", + "stars": 384956, + "forks": 80902, + "pushed_at": "2026-08-03T01:06:49Z", + "latest_release": "2026-08-02T08:11:31Z", + "fork_windows": [ + 1805, + 2187, + 3758, + 6995 + ] + }, + { + "driver": "hermes", + "repo": "NousResearch/hermes-agent", + "stars": 224327, + "forks": 43378, + "pushed_at": "2026-08-03T00:20:48Z", + "latest_release": "2026-07-30T23:45:37Z", + "fork_windows": [ + 6043, + 7598, + 10438, + 16303 + ] + }, + { + "driver": "nanobot", + "repo": "HKUDS/nanobot", + "pypi": "nanobot-ai", + "stars": 46521, + "forks": 8231, + "pushed_at": "2026-08-02T15:25:22Z", + "latest_release": "2026-07-25T08:08:47Z", + "fork_windows": [ + 366, + 316, + 482, + 811 + ], + "pypi_monthly": [ + { + "period": "2026-02", + "count": 50395 + }, + { + "period": "2026-03", + "count": 79326 + }, + { + "period": "2026-04", + "count": 53246 + }, + { + "period": "2026-05", + "count": 33854 + }, + { + "period": "2026-06", + "count": 33446 + }, + { + "period": "2026-07", + "count": 68201 + }, + { + "period": "2026-08", + "count": 2135 + } + ] + }, + { + "driver": "nanoclaw", + "repo": "nanocoai/nanoclaw", + "note": "canonical repo redirected from qwibitai/nanoclaw", + "stars": 30410, + "forks": 12871, + "pushed_at": "2026-08-02T14:13:24Z", + "latest_release": "2026-08-01T20:46:31Z", + "fork_windows": [ + 167, + 223, + 412, + 2355 + ] + }, + { + "driver": "picoclaw", + "repo": "sipeed/picoclaw", + "docker_hub": "sipeed/picoclaw", + "stars": 29799, + "forks": 4381, + "pushed_at": "2026-07-30T18:32:36Z", + "latest_release": "2026-07-02T01:26:53Z", + "fork_windows": [ + 315, + 102, + 156, + 305 + ] + }, + { + "driver": "microclaw", + "repo": "microclaw/microclaw", + "stars": 730, + "forks": 132, + "pushed_at": "2026-08-02T02:09:14Z", + "latest_release": "2026-08-01T21:26:19Z", + "fork_windows": [ + 0, + 3, + 10, + 8 + ] + }, + { + "driver": "nullclaw", + "repo": "nullclaw/nullclaw", + "stars": 7987, + "forks": 929, + "pushed_at": "2026-07-19T04:20:06Z", + "latest_release": "2026-05-29T13:41:10Z", + "fork_windows": [ + 32, + 22, + 44, + 44 + ] + } + ] +} diff --git a/examples/rollcall/Dockerfile.microclaw-base b/examples/rollcall/Dockerfile.microclaw-base deleted file mode 100644 index 53eabb2..0000000 --- a/examples/rollcall/Dockerfile.microclaw-base +++ /dev/null @@ -1,31 +0,0 @@ -# Base image for microclaw agents (test stub). -# Usage: docker build -t microclaw:latest -f Dockerfile.microclaw-base . -# -# MicroClaw is a fictional test runtime. This stub provides a long-running -# process named "microclaw" so the driver healthcheck (pgrep) passes. - -FROM alpine:3.20 - -RUN apk add --no-cache bash curl jq procps tini - -# Stub binary: long-running process with "microclaw" in its name -COPY <<'STUB' /usr/local/bin/microclaw -#!/bin/sh -echo "microclaw spike stub running (pid $$)" >&2 -while true; do sleep 3600; done -STUB -RUN chmod +x /usr/local/bin/microclaw - -COPY discord-responder.sh /usr/local/bin/discord-responder -RUN chmod +x /usr/local/bin/discord-responder - -# Entrypoint wrapper: start Discord responder in background, then run main process -COPY <<'WRAPPER' /usr/local/bin/microclaw-entrypoint -#!/bin/sh -/usr/local/bin/discord-responder & -exec microclaw -WRAPPER -RUN chmod +x /usr/local/bin/microclaw-entrypoint - -ENTRYPOINT ["/sbin/tini", "--"] -CMD ["microclaw-entrypoint"] diff --git a/examples/rollcall/Dockerfile.nanoclaw-base b/examples/rollcall/Dockerfile.nanoclaw-base deleted file mode 100644 index 11f474b..0000000 --- a/examples/rollcall/Dockerfile.nanoclaw-base +++ /dev/null @@ -1,38 +0,0 @@ -# Base image for nanoclaw (Claude Agent SDK) orchestrator. -# Usage: docker build -t nanoclaw-orchestrator:latest -f Dockerfile.nanoclaw-base . -# -# NanoClaw spawns ephemeral agent containers via the Docker socket. -# It requires the Claude Code SDK and Docker CLI. - -FROM node:22-slim - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates curl git tini python3 make g++ procps jq \ - && rm -rf /var/lib/apt/lists/* - -# Install Docker CLI (for spawning agent containers) -RUN curl -fsSL https://get.docker.com | sh - -# Install Claude Code SDK -RUN npm install -g @anthropic-ai/claude-code - -RUN mkdir -p /workspace/groups/main /workspace/container/skills - -WORKDIR /workspace - -HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ - CMD pgrep -f "node" > /dev/null || exit 1 - -COPY discord-responder.sh /usr/local/bin/discord-responder -RUN chmod +x /usr/local/bin/discord-responder - -# Entrypoint wrapper: start Discord responder in background, then run main process -COPY <<'WRAPPER' /usr/local/bin/nanoclaw-entrypoint -#!/bin/sh -/usr/local/bin/discord-responder & -exec node -e "console.log('nanoclaw orchestrator ready'); setInterval(() => {}, 60000)" -WRAPPER -RUN chmod +x /usr/local/bin/nanoclaw-entrypoint - -ENTRYPOINT ["/usr/bin/tini", "--"] -CMD ["nanoclaw-entrypoint"] diff --git a/examples/rollcall/Dockerfile.nullclaw-base b/examples/rollcall/Dockerfile.nullclaw-base deleted file mode 100644 index b0b058c..0000000 --- a/examples/rollcall/Dockerfile.nullclaw-base +++ /dev/null @@ -1,71 +0,0 @@ -# Base image for nullclaw agents (test stub). -# Usage: docker build -t nullclaw:latest -f Dockerfile.nullclaw-base . -# -# NullClaw is a fictional test runtime. This stub provides a minimal HTTP -# health endpoint on :3000 and cron subcommands for driver integration testing. - -FROM alpine:3.20 - -RUN apk add --no-cache curl jq tini python3 - -# Stub binary: health endpoint + cron commands + agent invocation -COPY <<'STUB' /usr/local/bin/nullclaw -#!/bin/sh -set -eu -cmd="${1:-run}" -case "$cmd" in - run) - mkdir -p /tmp/nullclaw-spike - printf "ok\n" >/tmp/nullclaw-spike/health - exec python3 -m http.server 3000 --directory /tmp/nullclaw-spike - ;; - cron) - sub="${2:-}" - case "$sub" in - list) - jobs_file=/tmp/nullclaw-spike/cron.jobs - count=0 - if [ -f "$jobs_file" ]; then count="$(wc -l <"$jobs_file" | tr -d ' ')"; fi - echo "info(cron): Scheduled jobs (${count}):" - if [ -f "$jobs_file" ]; then - i=0 - while IFS="$(printf '\t')" read -r expr command; do - [ -n "${expr:-}" ] || continue - i=$((i + 1)) - echo "info(cron): - job-${i} | ${expr} | next=0 | status=n/a cmd: ${command}" - done <"$jobs_file" - fi - ;; - add) - [ "$#" -ge 4 ] || { echo "error: usage: nullclaw cron add '' ''" >&2; exit 2; } - mkdir -p /tmp/nullclaw-spike - printf "%s\t%s\n" "$3" "$4" >>/tmp/nullclaw-spike/cron.jobs - echo "info(cron): added schedule '$3'" - ;; - *) echo "error: unknown nullclaw cron subcommand '$sub'" >&2; exit 2 ;; - esac - ;; - agent) - [ "${2:-}" = "-m" ] && { echo "nullclaw agent: ${3:-}"; exit 0; } - echo "error: usage: nullclaw agent -m " >&2; exit 2 - ;; - *) echo "error: unknown nullclaw subcommand '$cmd'" >&2; exit 2 ;; -esac -STUB -RUN chmod +x /usr/local/bin/nullclaw - -COPY discord-responder.sh /usr/local/bin/discord-responder -RUN chmod +x /usr/local/bin/discord-responder - -# Entrypoint wrapper: start Discord responder in background, then run main process -COPY <<'WRAPPER' /usr/local/bin/nullclaw-entrypoint -#!/bin/sh -/usr/local/bin/discord-responder & -exec nullclaw run -WRAPPER -RUN chmod +x /usr/local/bin/nullclaw-entrypoint - -EXPOSE 3000 - -ENTRYPOINT ["/sbin/tini", "--"] -CMD ["nullclaw-entrypoint"] diff --git a/examples/rollcall/agents/mc-roll/AGENTS.md b/examples/rollcall/agents/mc-roll/AGENTS.md deleted file mode 100644 index a56d4ae..0000000 --- a/examples/rollcall/agents/mc-roll/AGENTS.md +++ /dev/null @@ -1,16 +0,0 @@ -# mc-roll - -You are mc-roll, an agent running on the **MicroClaw** runtime. - -## CRITICAL: Tool-only mode - -Plain text responses are private thinking. They are NEVER sent to Discord. - -The ONLY way to communicate is by calling the `send_message` tool. When a message arrives, call `send_message` with one sentence stating your name and runtime. - -Example: -``` -send_message(message="I am mc-roll, running on the MicroClaw runtime.") -``` - -After the tool call completes, respond with only: `Done.` diff --git a/examples/rollcall/agents/mc-roll/Clawfile b/examples/rollcall/agents/mc-roll/Clawfile deleted file mode 100644 index 8e2ca29..0000000 --- a/examples/rollcall/agents/mc-roll/Clawfile +++ /dev/null @@ -1,6 +0,0 @@ -FROM microclaw:latest - -CLAW_TYPE microclaw -AGENT AGENTS.md -MODEL primary anthropic/claude-sonnet-4 -HANDLE discord diff --git a/examples/rollcall/agents/nano-roll/AGENTS.md b/examples/rollcall/agents/nano-roll/AGENTS.md deleted file mode 100644 index 83c4353..0000000 --- a/examples/rollcall/agents/nano-roll/AGENTS.md +++ /dev/null @@ -1,16 +0,0 @@ -# nano-roll - -You are nano-roll, an agent running on the **NanoClaw** (Claude Agent SDK) runtime. - -## CRITICAL: Tool-only mode - -Plain text responses are private thinking. They are NEVER sent to Discord. - -The ONLY way to communicate is by calling the `send_message` tool. When a message arrives, call `send_message` with one sentence stating your name and runtime. - -Example: -``` -send_message(message="I am nano-roll, running on the NanoClaw (Claude Agent SDK) runtime.") -``` - -After the tool call completes, respond with only: `Done.` diff --git a/examples/rollcall/agents/nano-roll/Clawfile b/examples/rollcall/agents/nano-roll/Clawfile deleted file mode 100644 index d01b1e3..0000000 --- a/examples/rollcall/agents/nano-roll/Clawfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM nanoclaw-orchestrator:latest - -CLAW_TYPE nanoclaw -AGENT AGENTS.md -MODEL primary anthropic/claude-sonnet-4-6 -PRIVILEGE docker-socket true -HANDLE discord diff --git a/examples/rollcall/agents/nc-roll/AGENTS.md b/examples/rollcall/agents/nc-roll/AGENTS.md deleted file mode 100644 index a7e7e11..0000000 --- a/examples/rollcall/agents/nc-roll/AGENTS.md +++ /dev/null @@ -1,16 +0,0 @@ -# nc-roll - -You are nc-roll, an agent running on the **NullClaw** runtime. - -## CRITICAL: Tool-only mode - -Plain text responses are private thinking. They are NEVER sent to Discord. - -The ONLY way to communicate is by calling the `send_message` tool. When a message arrives, call `send_message` with one sentence stating your name and runtime. - -Example: -``` -send_message(message="I am nc-roll, running on the NullClaw runtime.") -``` - -After the tool call completes, respond with only: `Done.` diff --git a/examples/rollcall/agents/nc-roll/Clawfile b/examples/rollcall/agents/nc-roll/Clawfile deleted file mode 100644 index 26548a2..0000000 --- a/examples/rollcall/agents/nc-roll/Clawfile +++ /dev/null @@ -1,6 +0,0 @@ -FROM nullclaw:latest - -CLAW_TYPE nullclaw -AGENT AGENTS.md -MODEL primary anthropic/claude-sonnet-4 -HANDLE discord diff --git a/examples/trading-desk/Clawfile.microclaw b/examples/trading-desk/Clawfile.microclaw deleted file mode 100644 index 66744d5..0000000 --- a/examples/trading-desk/Clawfile.microclaw +++ /dev/null @@ -1,16 +0,0 @@ -FROM alpine:3.20 - -CLAW_TYPE microclaw -AGENT AGENTS.md - -MODEL primary anthropic/claude-sonnet-4 - -HANDLE discord - -RUN apk add --no-cache bash curl jq procps tini -COPY microclaw /usr/local/bin/microclaw -RUN chmod +x /usr/local/bin/microclaw -COPY microclaw-entrypoint.sh /app/entrypoint.sh -RUN chmod +x /app/entrypoint.sh - -ENTRYPOINT ["/sbin/tini", "--", "/app/entrypoint.sh"] diff --git a/examples/trading-desk/Clawfile.nanoclaw b/examples/trading-desk/Clawfile.nanoclaw deleted file mode 100644 index f215297..0000000 --- a/examples/trading-desk/Clawfile.nanoclaw +++ /dev/null @@ -1,11 +0,0 @@ -FROM nanoclaw-orchestrator:latest - -CLAW_TYPE nanoclaw - -MODEL primary anthropic/claude-sonnet-4-6 - -HANDLE discord - -PRIVILEGE docker-socket true - -TRACK apt npm diff --git a/examples/trading-desk/Clawfile.nullclaw b/examples/trading-desk/Clawfile.nullclaw deleted file mode 100644 index 07aeb9d..0000000 --- a/examples/trading-desk/Clawfile.nullclaw +++ /dev/null @@ -1,16 +0,0 @@ -FROM alpine:3.20 - -CLAW_TYPE nullclaw -AGENT AGENTS.md - -MODEL primary anthropic/claude-sonnet-4 - -HANDLE discord - -RUN apk add --no-cache curl jq tini python3 -COPY nullclaw /usr/local/bin/nullclaw -RUN chmod +x /usr/local/bin/nullclaw -COPY nullclaw-entrypoint.sh /app/entrypoint.sh -RUN chmod +x /app/entrypoint.sh - -ENTRYPOINT ["/sbin/tini", "--", "/app/entrypoint.sh"] diff --git a/examples/trading-desk/agents/MICRO.md b/examples/trading-desk/agents/MICRO.md deleted file mode 100644 index 1aeabb7..0000000 --- a/examples/trading-desk/agents/MICRO.md +++ /dev/null @@ -1,8 +0,0 @@ -# MICRO — MicroClaw Operations Agent - -You are Micro, a low-latency operations agent for the trading desk. - -Core responsibilities: -- Monitor incident signals and post short operational updates in Discord. -- Escalate critical outages to the trading floor with concrete status. -- Keep updates concise, actionable, and timestamped. diff --git a/examples/trading-desk/agents/SYSTEMS-MONITOR.md b/examples/trading-desk/agents/SYSTEMS-MONITOR.md deleted file mode 100644 index cb9eedf..0000000 --- a/examples/trading-desk/agents/SYSTEMS-MONITOR.md +++ /dev/null @@ -1,41 +0,0 @@ -# Systems-Monitor — Agent Contract - -Systems monitor. Keeps the infrastructure healthy. Does not trade. - -## Startup - -Read `/claw/CLAWDAPUS.md`. Your surfaces are `trading-api` (REST health endpoint) -and `clawd-shared` (volume, monitor disk). Peer agent liveness is observed via -Discord — unresponsive agents won't post on schedule. Report to #infra. - -## Role - -Watch infrastructure: API health, container reachability, error patterns, disk -pressure on shared volumes. Don't interrupt traders with routine status. -Cross-post to #trading-floor only for critical systemic events. - -## Instructions - -On every heartbeat, check `GET /health` on `trading-api`, verify the response is -fresh, and note any non-`ok` status, repeated errors, or rising latency. Inspect -the shared volume for obvious disk pressure, missing research files, or write -failures that would block traders from updating state. - -Treat the following as actionable alerts: -- `trading-api` unavailable or returning errors for two consecutive checks -- shared volume close to full or becoming read-only -- repeated container restarts, missing Discord presence, or obvious dead-agent behavior -- any condition that can block trade proposal, compliance review, or execution - -Post routine operational status only in `#infra`. Use a terse format: -`[STATUS] component | state | impact | next check`. -Escalate to `#trading-floor` only when the problem affects trading decisions, -execution, or the whole desk. Include concrete operator action when you have one. - -Escalate to the human operator immediately for sustained API outage, data loss -risk, corrupted shared state, or any condition that leaves the desk trading blind. -Do not suggest trades or comment on market direction. - -## Communication - -Primary channel: #infra. Only use #trading-floor for critical systemic alerts. diff --git a/examples/trading-desk/microclaw b/examples/trading-desk/microclaw deleted file mode 100644 index 2d5c47a..0000000 --- a/examples/trading-desk/microclaw +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -# Minimal long-running process for trading spike image. -# Command name includes "microclaw" so driver healthcheck `pgrep -f microclaw` passes. - -echo "microclaw spike stub running (pid $$)" >&2 -while true; do - sleep 3600 -done diff --git a/examples/trading-desk/microclaw-entrypoint.sh b/examples/trading-desk/microclaw-entrypoint.sh deleted file mode 100644 index 4b66f3e..0000000 --- a/examples/trading-desk/microclaw-entrypoint.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/sh -set -e - -send_greeting() { - if [ -z "${DISCORD_BOT_TOKEN:-}" ] || [ -z "${CLAW_GREETING_CHANNEL:-}" ] || [ -z "${CLAW_GREETING_MESSAGE:-}" ]; then - return 0 - fi - - payload="$(jq -n --arg content "${CLAW_GREETING_MESSAGE}" '{content: $content}')" - i=0 - while [ "$i" -lt 5 ]; do - if curl -fsS -X POST "https://discord.com/api/v10/channels/${CLAW_GREETING_CHANNEL}/messages" \ - -H "Authorization: Bot ${DISCORD_BOT_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "$payload" >/dev/null; then - echo "[microclaw-entrypoint] greeting sent" - return 0 - fi - i=$((i + 1)) - sleep 3 - done - - echo "[microclaw-entrypoint] warning: failed to send greeting after retries" -} - -send_greeting -exec microclaw run diff --git a/examples/trading-desk/nullclaw b/examples/trading-desk/nullclaw deleted file mode 100644 index d6a0746..0000000 --- a/examples/trading-desk/nullclaw +++ /dev/null @@ -1,60 +0,0 @@ -#!/bin/sh -set -eu - -cmd="${1:-run}" - -case "$cmd" in - run) - # Minimal health endpoint expected by the nullclaw driver. - mkdir -p /tmp/nullclaw-spike - printf "ok\n" >/tmp/nullclaw-spike/health - exec python3 -m http.server 3000 --directory /tmp/nullclaw-spike - ;; - cron) - sub="${2:-}" - case "$sub" in - list) - jobs_file=/tmp/nullclaw-spike/cron.jobs - count=0 - if [ -f "$jobs_file" ]; then - count="$(wc -l <"$jobs_file" | tr -d ' ')" - fi - echo "info(cron): Scheduled jobs (${count}):" - if [ -f "$jobs_file" ]; then - i=0 - while IFS="$(printf '\t')" read -r expr command; do - [ -n "${expr:-}" ] || continue - i=$((i + 1)) - echo "info(cron): - job-${i} | ${expr} | next=0 | status=n/a cmd: ${command}" - done <"$jobs_file" - fi - ;; - add) - if [ "$#" -lt 4 ]; then - echo "error: usage: nullclaw cron add '' ''" >&2 - exit 2 - fi - mkdir -p /tmp/nullclaw-spike - printf "%s\t%s\n" "$3" "$4" >>/tmp/nullclaw-spike/cron.jobs - echo "info(cron): added schedule '$3'" - ;; - *) - echo "error: unknown nullclaw cron subcommand '$sub'" >&2 - exit 2 - ;; - esac - ;; - agent) - # Spike stub accepts agent invocations so PostApply cron wiring can call it. - if [ "${2:-}" = "-m" ]; then - echo "nullclaw agent: ${3:-}" - exit 0 - fi - echo "error: usage: nullclaw agent -m " >&2 - exit 2 - ;; - *) - echo "error: unknown nullclaw subcommand '$cmd'" >&2 - exit 2 - ;; -esac diff --git a/examples/trading-desk/nullclaw-entrypoint.sh b/examples/trading-desk/nullclaw-entrypoint.sh deleted file mode 100644 index 84102fe..0000000 --- a/examples/trading-desk/nullclaw-entrypoint.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/sh -set -e - -send_greeting() { - if [ -z "${DISCORD_BOT_TOKEN:-}" ] || [ -z "${CLAW_GREETING_CHANNEL:-}" ] || [ -z "${CLAW_GREETING_MESSAGE:-}" ]; then - return 0 - fi - - payload="$(jq -n --arg content "${CLAW_GREETING_MESSAGE}" '{content: $content}')" - i=0 - while [ "$i" -lt 5 ]; do - if curl -fsS -X POST "https://discord.com/api/v10/channels/${CLAW_GREETING_CHANNEL}/messages" \ - -H "Authorization: Bot ${DISCORD_BOT_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "$payload" >/dev/null; then - echo "[nullclaw-entrypoint] greeting sent" - return 0 - fi - i=$((i + 1)) - sleep 3 - done - - echo "[nullclaw-entrypoint] warning: failed to send greeting after retries" -} - -send_greeting -exec nullclaw run diff --git a/testdata/microclaw-stub/AGENTS.md b/testdata/microclaw-stub/AGENTS.md deleted file mode 100644 index 9891272..0000000 --- a/testdata/microclaw-stub/AGENTS.md +++ /dev/null @@ -1,3 +0,0 @@ -# MicroClaw Stub Contract - -You are a microclaw stub agent used for clawdapus integration testing. diff --git a/testdata/microclaw-stub/Clawfile b/testdata/microclaw-stub/Clawfile deleted file mode 100644 index e98c0e7..0000000 --- a/testdata/microclaw-stub/Clawfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM alpine:3.20 - -CLAW_TYPE microclaw -AGENT AGENTS.md - -MODEL primary anthropic/claude-sonnet-4 - -RUN apk add --no-cache bash tini procps -COPY microclaw /usr/local/bin/microclaw -RUN chmod +x /usr/local/bin/microclaw -COPY entrypoint.sh /app/entrypoint.sh -RUN chmod +x /app/entrypoint.sh -ENTRYPOINT ["/sbin/tini", "--", "/app/entrypoint.sh"] diff --git a/testdata/microclaw-stub/entrypoint.sh b/testdata/microclaw-stub/entrypoint.sh deleted file mode 100644 index 695dbc4..0000000 --- a/testdata/microclaw-stub/entrypoint.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -set -e - -exec microclaw run diff --git a/testdata/microclaw-stub/microclaw b/testdata/microclaw-stub/microclaw deleted file mode 100644 index 4902696..0000000 --- a/testdata/microclaw-stub/microclaw +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -# Stub microclaw process for integration/spike tests. -# Keep this script process alive so `pgrep -f microclaw` healthcheck passes. - -echo "microclaw-stub: running (pid $$)" >&2 -while true; do - sleep 3600 -done From de3dc715fd127bb6e2a354ba5a28d0189f0e70c0 Mon Sep 17 00:00:00 2001 From: Wojtek Date: Sun, 2 Aug 2026 21:45:57 -0400 Subject: [PATCH 3/4] Retire NanoClaw, MicroClaw, and NullClaw drivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the ADR-026 removal across the compile-time surface. RED-first: internal/driver/retired_test.go pins the retained set {openclaw, hermes, nanobot, picoclaw} and requires retired CLAW_TYPEs to fail closed with the runner named, the retirement reason, ADR-026, and a supported migration target — not the generic unknown-driver message. Lookup() carries a retiredRunners map to satisfy it. Removed: internal/driver/{nanoclaw,microclaw,nullclaw}, their blank imports, scaffold types and base-image aliases, schedule wake adapters, shared history locations, testdata/microclaw-stub, the three rollcall services/agents/Dockerfiles, and the trading-desk systems-monitor and micro services. Conformance coverage is preserved, not just shrunk: - rollcall's nb-roll flips to the anthropic-messages proxy format so that ingress surface keeps a default-run carrier (its previous carriers nc-roll/nano-roll are gone and pc-roll is gated behind #137; ADR-023 coverage assertion unchanged) - trading-desk value-trader retargets to a nanobot stub, keeping a third driver family in the master-claw wiring spike; assertions now check nanobot-home/config.json and the seeded workspace AGENTS.md - the mixed-managed spike pairs openclaw with a new testdata/ nanobot-stub; both retained drivers are container-read-only, so the old read-only/writable contrast (a microclaw-specific property) is replaced with env/config assertions - the channel-digest and policy spikes retarget their stub agent to nanobot Docs: README, site guide pages, AGENTS.md, embedded skill text (synced via go generate), issue template, .env.example. Historical record — docs/plans, superseded ADRs, existing changelog entries — deliberately untouched per ADR-026. Changelog gains an Unreleased entry only. Verified: go build, go test ./... , go vet ./... (plus spike and integration tags), go test -tags integration ./... against real Docker, gofmt, git diff --check. --- .github/ISSUE_TEMPLATE/bug_report.yml | 3 - .gitignore | 2 +- AGENTS.md | 10 +- README.md | 48 +- cmd/claw-api/agent_context_test.go | 2 +- cmd/claw-api/handler.go | 3 - cmd/claw/agent_test.go | 5 +- cmd/claw/compose_health.go | 3 - cmd/claw/compose_test.go | 2 +- cmd/claw/init_test.go | 5 +- cmd/claw/scaffold_helpers.go | 10 +- cmd/claw/scaffold_helpers_test.go | 6 - cmd/claw/schedule_manifest.go | 10 +- cmd/claw/schedule_manifest_test.go | 4 +- cmd/claw/skill_data/SKILL.md | 5 +- cmd/claw/spike_channel_digest_test.go | 6 +- cmd/claw/spike_mixed_managed_test.go | 66 +-- cmd/claw/spike_policy_test.go | 6 +- cmd/claw/spike_rollcall_test.go | 49 +- cmd/claw/spike_test.go | 189 ++------ cmd/claw/trading_desk_contracts_test.go | 1 - examples/rollcall/README.md | 14 +- examples/rollcall/agents/nb-roll/Clawfile | 2 +- examples/rollcall/claw-pod.yml | 82 +--- examples/trading-desk/.env.example | 4 - examples/trading-desk/Clawfile.nanobot | 16 + examples/trading-desk/README.md | 12 +- examples/trading-desk/claw-pod.yml | 52 +-- examples/trading-desk/nanobot | 41 ++ examples/trading-desk/nanobot-entrypoint.sh | 27 ++ internal/build/build.go | 3 - internal/build/build_test.go | 28 -- internal/driver/microclaw/baseimage.go | 23 - internal/driver/microclaw/baseimage_test.go | 29 -- internal/driver/microclaw/driver.go | 487 -------------------- internal/driver/microclaw/driver_test.go | 308 ------------- internal/driver/nanoclaw/baseimage.go | 44 -- internal/driver/nanoclaw/baseimage_test.go | 35 -- internal/driver/nanoclaw/driver.go | 169 ------- internal/driver/nanoclaw/driver_test.go | 349 -------------- internal/driver/nullclaw/baseimage.go | 16 - internal/driver/nullclaw/baseimage_test.go | 26 -- internal/driver/nullclaw/config.go | 162 ------- internal/driver/nullclaw/config_test.go | 250 ---------- internal/driver/nullclaw/driver.go | 339 -------------- internal/driver/nullclaw/driver_test.go | 332 ------------- internal/driver/registry.go | 13 + internal/driver/retired_test.go | 55 +++ internal/driver/shared/history.go | 6 - internal/pod/parser_handles_test.go | 6 +- site/changelog.md | 1 + site/guide/anatomy.md | 3 - site/guide/clawfile.md | 30 +- site/guide/cli.md | 2 +- site/guide/drivers.md | 47 +- site/guide/social-topology.md | 12 +- skills/clawdapus/SKILL.md | 5 +- testdata/nanobot-stub/AGENTS.md | 3 + testdata/nanobot-stub/Clawfile | 13 + testdata/nanobot-stub/entrypoint.sh | 4 + testdata/nanobot-stub/nanobot | 8 + 61 files changed, 368 insertions(+), 3125 deletions(-) create mode 100644 examples/trading-desk/Clawfile.nanobot create mode 100755 examples/trading-desk/nanobot create mode 100755 examples/trading-desk/nanobot-entrypoint.sh delete mode 100644 internal/driver/microclaw/baseimage.go delete mode 100644 internal/driver/microclaw/baseimage_test.go delete mode 100644 internal/driver/microclaw/driver.go delete mode 100644 internal/driver/microclaw/driver_test.go delete mode 100644 internal/driver/nanoclaw/baseimage.go delete mode 100644 internal/driver/nanoclaw/baseimage_test.go delete mode 100644 internal/driver/nanoclaw/driver.go delete mode 100644 internal/driver/nanoclaw/driver_test.go delete mode 100644 internal/driver/nullclaw/baseimage.go delete mode 100644 internal/driver/nullclaw/baseimage_test.go delete mode 100644 internal/driver/nullclaw/config.go delete mode 100644 internal/driver/nullclaw/config_test.go delete mode 100644 internal/driver/nullclaw/driver.go delete mode 100644 internal/driver/nullclaw/driver_test.go create mode 100644 internal/driver/retired_test.go create mode 100644 testdata/nanobot-stub/AGENTS.md create mode 100644 testdata/nanobot-stub/Clawfile create mode 100755 testdata/nanobot-stub/entrypoint.sh create mode 100755 testdata/nanobot-stub/nanobot diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f48be50..e17e6ad 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -18,11 +18,8 @@ body: options: - openclaw - hermes - - nanoclaw - nanobot - picoclaw - - nullclaw - - microclaw - generic / none - not driver-related validations: diff --git a/.gitignore b/.gitignore index c2b1d8e..09d3ce4 100644 --- a/.gitignore +++ b/.gitignore @@ -48,7 +48,7 @@ cmd/clawdash/node_modules/ testdata/* !testdata/openclaw-stub/ !testdata/openclaw-stub-nonroot/ -!testdata/microclaw-stub/ +!testdata/nanobot-stub/ !testdata/tool-stub/ *-notes.md .claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md index 668a259..ae60f2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,12 +142,12 @@ Driver directories currently in-tree: - `internal/driver/openclaw` - `internal/driver/hermes` - `internal/driver/nanobot` -- `internal/driver/nanoclaw` - `internal/driver/picoclaw` -- `internal/driver/microclaw` -- `internal/driver/nullclaw` - `internal/driver/shared` +NanoClaw, MicroClaw, and NullClaw were retired in ADR-026; their `CLAW_TYPE`s fail +closed with a migration message. + Do not assume older docs mentioning only a subset are current. ## Runtime Model That Exists Today @@ -162,13 +162,13 @@ Do not assume older docs mentioning only a subset are current. ## Repo-Specific Gotchas -- Bug fixes in one driver often apply to all 7. When fixing driver behavior (permissions, config defaults, env vars), check all drivers in `internal/driver/*/driver.go` and `config.go` — not just the one mentioned in the issue. +- Bug fixes in one driver often apply to all 4. When fixing driver behavior (permissions, config defaults, env vars), check all drivers in `internal/driver/*/driver.go` and `config.go` — not just the one mentioned in the issue. - Runtime directories created by `Materialize()` use `0o777` (not `0o700`) so container users with different uids can write. Do not regress this. - All drivers set `mention_only` (or equivalent like `requireMention`, `DISCORD_REQUIRE_MENTION`) for Discord channels. Without this, multi-agent pods enter feedback loops. - All drivers explicitly set `HOME` in the container env map to match their config mount path. Container base images may run as root or a different user than expected. - `cllama/` is a git submodule pointing to a public SSH repo. Fresh `git clone` leaves it empty. Infra images (cllama, clawdash) are published to ghcr.io as public packages to avoid this for end users. `cllama/` has its own `.git` — changes require two commits: one inside `cllama/` (for feeds/proxy code), then `git add cllama && git commit` in the repo root to update the pointer. Shell working directory can silently drift to `cllama/` between commands — use absolute paths for git operations or verify with `pwd` first. - `internal/feeds/` and other cllama internals live at `cllama/internal/`, not at the repo root. -- Infra image remediation is explicit now: `claw pull` owns pinned infra freshness *and* built-in local runner alias freshness (`openclaw:latest`, `nanobot:latest`, `nanoclaw-orchestrator:latest`, etc.) refreshed via `docker build --pull --no-cache`; `claw build` owns pod `build:` services and consumes an already-refreshed runner alias; `claw up` points at one or the other when something is missing. `claw up --fix` is the opt-in auto-remediation path. Use `claw pull --no-runners` for the fast pinned-infra-only path. See ADR-024. +- Infra image remediation is explicit now: `claw pull` owns pinned infra freshness *and* built-in local runner alias freshness (`openclaw:latest`, `nanobot:latest`, `picoclaw:latest`, etc.) refreshed via `docker build --pull --no-cache`; `claw build` owns pod `build:` services and consumes an already-refreshed runner alias; `claw up` points at one or the other when something is missing. `claw up --fix` is the opt-in auto-remediation path. Use `claw pull --no-runners` for the fast pinned-infra-only path. See ADR-024. - Runner base provenance: `claw build` rewrites `FROM :latest` to `FROM :v` in `Dockerfile.generated` and stamps three labels (`claw.runner.built-against`, `claw.runner.image-id`, `claw.runner.recipe-sha`). `claw up` reads `claw.runner.image-id` and prints a soft drift hint when the local alias has moved on; it does not auto-rebuild. Service images built with a manual `docker build` of a runner base must also create a versioned sibling tag, otherwise `claw build` will fail-closed with a `claw pull` remediation hint. - Managed services require `claw up -d` because post-apply verification is fail-closed. - Multi-proxy cllama is represented in the data model but runtime currently fails fast if more than one proxy type is declared. diff --git a/README.md b/README.md index d781c46..2163522 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ The Clawfile extends the Dockerfile with directives that the `claw build` prepro | Directive | Purpose | |---|---| -| `CLAW_TYPE` | Selects the runtime driver (openclaw, hermes, nanobot, picoclaw, nanoclaw, microclaw, nullclaw) | +| `CLAW_TYPE` | Selects the runtime driver (openclaw, hermes, nanobot, picoclaw) | | `AGENT` | Names the behavioral contract file | | `PERSONA` | Imports a persona workspace — local path or OCI artifact ref | | `MODEL` | Binds named model slots to providers | @@ -285,18 +285,21 @@ The Clawfile extends the Dockerfile with directives that the `claw build` prepro Pick a driver based on what you need. All drivers support `MODEL`, `AGENT`, `CLLAMA`, and `CONFIGURE`. -| | `openclaw` | `hermes` | `nanoclaw` | `nanobot` | `picoclaw` | `nullclaw` | `microclaw` | -|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:| -| **Runtime** | [OpenClaw](https://openclaw.ai) | [Hermes](https://github.com/NousResearch/hermes-agent) | NanoClaw / Claude Code-compatible orchestrator | [Nanobot](https://github.com/HKUDS/nanobot) | [PicoClaw](https://github.com/sipeed/picoclaw) | [NullClaw](https://github.com/nullclaw/nullclaw) | [MicroClaw](https://github.com/microclaw/microclaw) | -| `claw init` scaffold | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| HANDLE: Discord | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | -| HANDLE: Telegram | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | -| HANDLE: Slack | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | -| HANDLE: long-tail ¹ | — | — | — | — | ✅ | — | — | -| INVOKE (cron) | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | -| Structured health | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Read-only rootfs | ✅ | ✅ | no | ✅ | ✅ | ✅ | no | -| Non-root container | — | — | — | — | ✅ | — | — | +| | `openclaw` | `hermes` | `nanobot` | `picoclaw` | +|---|:---:|:---:|:---:|:---:| +| **Runtime** | [OpenClaw](https://openclaw.ai) | [Hermes](https://github.com/NousResearch/hermes-agent) | [Nanobot](https://github.com/HKUDS/nanobot) | [PicoClaw](https://github.com/sipeed/picoclaw) | +| `claw init` scaffold | ✅ | ✅ | ✅ | ✅ | +| HANDLE: Discord | ✅ | ✅ | ✅ | ✅ | +| HANDLE: Telegram | ✅ | ✅ | ✅ | ✅ | +| HANDLE: Slack | ✅ | ✅ | ✅ | ✅ | +| HANDLE: long-tail ¹ | — | — | — | ✅ | +| INVOKE (cron) | ✅ | ✅ | ✅ | ✅ | +| Structured health | ✅ | ✅ | ✅ | ✅ | +| Read-only rootfs | ✅ | ✅ | ✅ | ✅ | +| Non-root container | — | — | — | ✅ | + +Retired drivers (`nanoclaw`, `microclaw`, `nullclaw`) fail `claw up` with a migration +message; see [ADR-026](docs/decisions/026-runner-adoption-and-retirement.md). ¹ PicoClaw long-tail: WhatsApp, Feishu, LINE, QQ, DingTalk, OneBot, WeCom, WeCom App, Pico, MaixCam. `claw init` scaffolds `generic` (alpine:3.20, no driver enforcement) for custom runtimes. @@ -319,7 +322,7 @@ The OpenClaw driver now maps the supported `channel://discord` routing controls --- -## Nullclaw `CONFIGURE` Examples +## Driver `CONFIGURE` Examples Use these when you want high-level `HANDLE` defaults, but need runtime-specific policy details. @@ -327,21 +330,18 @@ Use these when you want high-level `HANDLE` defaults, but need runtime-specific # Base identity on a platform: HANDLE discord -# "Can talk on" -> pin to one guild/server -CONFIGURE nullclaw config set channels.discord.accounts.main.guild_id "123456789012345678" +# Enable/adjust a channel beyond HANDLE defaults +CONFIGURE picoclaw config set channels.discord.enabled true -# "Can talk to" -> require mention in group chats -CONFIGURE nullclaw config set channels.discord.accounts.main.require_mention true +# Override the gateway port +CONFIGURE picoclaw config set gateway.port 19000 -# Telegram allowlist for DMs -CONFIGURE nullclaw config set channels.telegram.accounts.main.allow_from ["111111111","222222222"] - -# Slack transport mode selection -CONFIGURE nullclaw config set channels.slack.accounts.main.mode "socket" +# Pin a fallback model name +CONFIGURE picoclaw config set agents.defaults.model_name "fallback" ``` Notes: -- `CONFIGURE` is driver-side DSL here (`nullclaw config set `), applied to generated `config.json`. +- `CONFIGURE` is driver-side DSL (` config set `), applied to the generated config. - Values are parsed as JSON when possible: booleans/numbers/arrays/objects should be unquoted; strings should be quoted. - `CONFIGURE` runs after defaults, so it overrides what `HANDLE` generated. diff --git a/cmd/claw-api/agent_context_test.go b/cmd/claw-api/agent_context_test.go index ea5a7c3..a14d929 100644 --- a/cmd/claw-api/agent_context_test.go +++ b/cmd/claw-api/agent_context_test.go @@ -21,7 +21,7 @@ func TestAgentsListFiltersByServiceScopeAndLiveContext(t *testing.T) { }) writeAgentContextFixture(t, contextRoot, "analyst-0", map[string]string{ "service": "analyst", - "type": "nanoclaw", + "type": "nanobot", }) var sawAuth string diff --git a/cmd/claw-api/handler.go b/cmd/claw-api/handler.go index 38b70b9..f1ca7f5 100644 --- a/cmd/claw-api/handler.go +++ b/cmd/claw-api/handler.go @@ -27,10 +27,7 @@ import ( manifestpkg "github.com/mostlydev/clawdapus/internal/clawdash" "github.com/mostlydev/clawdapus/internal/driver" _ "github.com/mostlydev/clawdapus/internal/driver/hermes" - _ "github.com/mostlydev/clawdapus/internal/driver/microclaw" _ "github.com/mostlydev/clawdapus/internal/driver/nanobot" - _ "github.com/mostlydev/clawdapus/internal/driver/nanoclaw" - _ "github.com/mostlydev/clawdapus/internal/driver/nullclaw" _ "github.com/mostlydev/clawdapus/internal/driver/openclaw" _ "github.com/mostlydev/clawdapus/internal/driver/picoclaw" schedulepkg "github.com/mostlydev/clawdapus/internal/schedule" diff --git a/cmd/claw/agent_test.go b/cmd/claw/agent_test.go index 04d86c5..1f8a706 100644 --- a/cmd/claw/agent_test.go +++ b/cmd/claw/agent_test.go @@ -220,9 +220,6 @@ func TestAgentAddTypeDefaults(t *testing.T) { }{ {name: "generic", agentName: "genericone", clawType: "generic", baseImage: "alpine:3.20"}, {name: "hermes", agentName: "hermesone", clawType: "hermes", baseImage: hermes.BaseImageTag}, - {name: "nanoclaw", agentName: "nanoclawone", clawType: "nanoclaw", baseImage: "nanoclaw-orchestrator:latest"}, - {name: "microclaw", agentName: "microclawone", clawType: "microclaw", baseImage: "microclaw:latest"}, - {name: "nullclaw", agentName: "nullclawone", clawType: "nullclaw", baseImage: "nullclaw:latest"}, {name: "nanobot", agentName: "nanobotone", clawType: "nanobot", baseImage: "nanobot:latest"}, {name: "picoclaw", agentName: "picoclawone", clawType: "picoclaw", baseImage: "picoclaw:latest"}, } @@ -266,7 +263,7 @@ func TestAgentAddTypeFlagUsageListsAllScaffoldTypes(t *testing.T) { } usage := flag.Usage - for _, typ := range []string{"openclaw", "hermes", "nanoclaw", "microclaw", "nullclaw", "nanobot", "picoclaw", "generic"} { + for _, typ := range []string{"openclaw", "hermes", "nanobot", "picoclaw", "generic"} { if !strings.Contains(usage, typ) { t.Fatalf("expected agent add --type usage to include %q, got: %s", typ, usage) } diff --git a/cmd/claw/compose_health.go b/cmd/claw/compose_health.go index cd047e8..b116afd 100644 --- a/cmd/claw/compose_health.go +++ b/cmd/claw/compose_health.go @@ -14,10 +14,7 @@ import ( "github.com/mostlydev/clawdapus/internal/driver" _ "github.com/mostlydev/clawdapus/internal/driver/hermes" - _ "github.com/mostlydev/clawdapus/internal/driver/microclaw" _ "github.com/mostlydev/clawdapus/internal/driver/nanobot" - _ "github.com/mostlydev/clawdapus/internal/driver/nanoclaw" - _ "github.com/mostlydev/clawdapus/internal/driver/nullclaw" _ "github.com/mostlydev/clawdapus/internal/driver/openclaw" _ "github.com/mostlydev/clawdapus/internal/driver/picoclaw" ) diff --git a/cmd/claw/compose_test.go b/cmd/claw/compose_test.go index 43d2ee6..2ff39cb 100644 --- a/cmd/claw/compose_test.go +++ b/cmd/claw/compose_test.go @@ -219,7 +219,7 @@ func TestComposePassthroughAllowsStaleGeneratedOnlyForBuild(t *testing.T) { } func TestBuiltinDriversRegistered(t *testing.T) { - for _, clawType := range []string{"openclaw", "hermes", "nanoclaw", "microclaw", "nullclaw", "nanobot", "picoclaw"} { + for _, clawType := range []string{"openclaw", "hermes", "nanobot", "picoclaw"} { if _, err := driver.Lookup(clawType); err != nil { t.Fatalf("expected driver %q to be registered in CLI package: %v", clawType, err) } diff --git a/cmd/claw/init_test.go b/cmd/claw/init_test.go index 9b1f174..7e0ffd1 100644 --- a/cmd/claw/init_test.go +++ b/cmd/claw/init_test.go @@ -285,9 +285,6 @@ func TestInitScaffoldTypeDefaults(t *testing.T) { }{ {name: "generic", clawType: "generic", baseImage: "alpine:3.20"}, {name: "hermes", clawType: "hermes", baseImage: hermes.BaseImageTag}, - {name: "nanoclaw", clawType: "nanoclaw", baseImage: "nanoclaw-orchestrator:latest"}, - {name: "microclaw", clawType: "microclaw", baseImage: "microclaw:latest"}, - {name: "nullclaw", clawType: "nullclaw", baseImage: "nullclaw:latest"}, {name: "nanobot", clawType: "nanobot", baseImage: "nanobot:latest"}, {name: "picoclaw", clawType: "picoclaw", baseImage: "picoclaw:latest"}, } @@ -362,7 +359,7 @@ func TestInitTypeFlagUsageListsAllScaffoldTypes(t *testing.T) { } usage := flag.Usage - for _, typ := range []string{"openclaw", "hermes", "nanoclaw", "microclaw", "nullclaw", "nanobot", "picoclaw", "generic"} { + for _, typ := range []string{"openclaw", "hermes", "nanobot", "picoclaw", "generic"} { if !strings.Contains(usage, typ) { t.Fatalf("expected init --type usage to include %q, got: %s", typ, usage) } diff --git a/cmd/claw/scaffold_helpers.go b/cmd/claw/scaffold_helpers.go index 6f12bce..ca2e1a2 100644 --- a/cmd/claw/scaffold_helpers.go +++ b/cmd/claw/scaffold_helpers.go @@ -24,7 +24,7 @@ const ( var validNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) -var scaffoldClawTypes = []string{"openclaw", "hermes", "nanoclaw", "microclaw", "nullclaw", "nanobot", "picoclaw", "generic"} +var scaffoldClawTypes = []string{"openclaw", "hermes", "nanobot", "picoclaw", "generic"} func shouldPromptInteractively() bool { info, err := os.Stdin.Stat() @@ -157,7 +157,7 @@ func parseClawType(value string) (string, error) { switch v { case "": return "", fmt.Errorf("claw type is required") - case "openclaw", "hermes", "nanoclaw", "microclaw", "nullclaw", "nanobot", "picoclaw", "generic": + case "openclaw", "hermes", "nanobot", "picoclaw", "generic": return v, nil default: return "", fmt.Errorf("invalid claw type %q (allowed: %s)", value, strings.Join(scaffoldClawTypes, ", ")) @@ -170,12 +170,6 @@ func defaultBaseImageForClawType(clawType string) string { return "openclaw:latest" case "hermes": return hermes.BaseImageTag - case "nanoclaw": - return "nanoclaw-orchestrator:latest" - case "microclaw": - return "microclaw:latest" - case "nullclaw": - return "nullclaw:latest" case "nanobot": return "nanobot:latest" case "picoclaw": diff --git a/cmd/claw/scaffold_helpers_test.go b/cmd/claw/scaffold_helpers_test.go index 430ad4b..50f03a5 100644 --- a/cmd/claw/scaffold_helpers_test.go +++ b/cmd/claw/scaffold_helpers_test.go @@ -11,9 +11,6 @@ func TestParseClawTypeAcceptsSupportedValues(t *testing.T) { tests := []string{ "openclaw", "hermes", - "nanoclaw", - "microclaw", - "nullclaw", "nanobot", "picoclaw", "generic", @@ -53,9 +50,6 @@ func TestDefaultBaseImageForClawType(t *testing.T) { }{ {name: "openclaw", clawType: "openclaw", want: "openclaw:latest"}, {name: "hermes", clawType: "hermes", want: hermes.BaseImageTag}, - {name: "nanoclaw", clawType: "nanoclaw", want: "nanoclaw-orchestrator:latest"}, - {name: "microclaw", clawType: "microclaw", want: "microclaw:latest"}, - {name: "nullclaw", clawType: "nullclaw", want: "nullclaw:latest"}, {name: "nanobot", clawType: "nanobot", want: "nanobot:latest"}, {name: "picoclaw", clawType: "picoclaw", want: "picoclaw:latest"}, {name: "generic", clawType: "generic", want: "alpine:3.20"}, diff --git a/cmd/claw/schedule_manifest.go b/cmd/claw/schedule_manifest.go index e96817d..cc7da5a 100644 --- a/cmd/claw/schedule_manifest.go +++ b/cmd/claw/schedule_manifest.go @@ -107,7 +107,7 @@ func buildScheduleManifest(p *pod.Pod, resolved map[string]*driver.ResolvedClaw) func supportsExternalScheduler(clawType string) bool { switch strings.TrimSpace(clawType) { - case "openclaw", "hermes", "nanobot", "picoclaw", "nullclaw": + case "openclaw", "hermes", "nanobot", "picoclaw": return true default: return false @@ -150,12 +150,6 @@ func resolveWakeAdapter(clawType, target string, inv driver.Invocation) (schedul Target: target, Command: []string{"picoclaw", "agent", "-m", inv.Message}, }, nil - case "nullclaw": - return schedule.Wake{ - Adapter: "nullclaw-exec", - Target: target, - Command: []string{"nullclaw", "agent", "-m", inv.Message}, - }, nil default: return schedule.Wake{}, fmt.Errorf("unsupported wake adapter for driver %q", clawType) } @@ -166,7 +160,7 @@ func scheduleWakeWarning(serviceName, clawType string, inv driver.Invocation) st return "" } switch strings.TrimSpace(clawType) { - case "picoclaw", "nullclaw": + case "picoclaw": return fmt.Sprintf("service %q: driver %q external scheduler wake does not support invoke.to=%q; firing direct agent message without delivery routing", serviceName, clawType, inv.To) default: return "" diff --git a/cmd/claw/schedule_manifest_test.go b/cmd/claw/schedule_manifest_test.go index e1f8330..ee1ec9a 100644 --- a/cmd/claw/schedule_manifest_test.go +++ b/cmd/claw/schedule_manifest_test.go @@ -107,7 +107,7 @@ func TestBuildScheduleManifestUsesServiceTimezoneWithoutCalendar(t *testing.T) { manifest, err := buildScheduleManifest(&pod.Pod{Name: "ops"}, map[string]*driver.ResolvedClaw{ "bot": { ServiceName: "bot", - ClawType: "nullclaw", + ClawType: "picoclaw", Timezone: "America/New_York", Invocations: []driver.Invocation{ { @@ -134,7 +134,7 @@ func TestBuildScheduleManifestFallsBackToUTCWithoutServiceTimezone(t *testing.T) manifest, err := buildScheduleManifest(&pod.Pod{Name: "ops"}, map[string]*driver.ResolvedClaw{ "bot": { ServiceName: "bot", - ClawType: "nullclaw", + ClawType: "picoclaw", Invocations: []driver.Invocation{ { ID: "podjob01", diff --git a/cmd/claw/skill_data/SKILL.md b/cmd/claw/skill_data/SKILL.md index 4ededea..507b8c4 100644 --- a/cmd/claw/skill_data/SKILL.md +++ b/cmd/claw/skill_data/SKILL.md @@ -445,10 +445,9 @@ When the aggregate cap drops a feed the model sees an explicit `--- FEED: | OpenClaw | `openclaw` | OpenClaw | JSON5 Go-native patching -> `openclaw.json` | Primary driver. Read-only container. Docker exec health probe. | | Hermes | `hermes` | Hermes (Python) | `config.yaml` + `.env` | Discord/Telegram/Slack. `HERMES_TOOL_ONLY_MODE`. Requires at least one handle. | | NanoBot | `nanobot` | Nanobot (Node.js) | `config.json` | Cron via `jobs.json`. Merged AGENTS.md. | -| NanoClaw | `nanoclaw` | Claude Agent SDK | Combined `CLAUDE.md` | Requires `PRIVILEGE docker-socket true`. Mounts Docker socket. | | PicoClaw | `picoclaw` | PicoClaw | `config.json` | HTTP `/health` + `/ready` probe. Read-only container. | -| MicroClaw | `microclaw` | MicroClaw (YAML) | `microclaw.config.yaml` | Built-in web UI on port 10961. No INVOKE support. | -| NullClaw | `nullclaw` | NullClaw (HTTP) | `config.json` | Cron via `PostApply` exec (not pre-written). Read-only container. | + +Retired (ADR-026): `nanoclaw`, `microclaw`, `nullclaw` — these `CLAW_TYPE`s fail `claw up` with a migration message. All drivers set `CLAW_MANAGED=true`, explicit `HOME`, and `DISCORD_REQUIRE_MENTION` (or equivalent) to prevent feedback loops. diff --git a/cmd/claw/spike_channel_digest_test.go b/cmd/claw/spike_channel_digest_test.go index 43d3a62..d4765e9 100644 --- a/cmd/claw/spike_channel_digest_test.go +++ b/cmd/claw/spike_channel_digest_test.go @@ -67,11 +67,11 @@ func TestSpikeChannelDigestGeneratedPod(t *testing.T) { spikeBuildImage(t, repoRoot, channelMemoryImage, "examples/channel-memory/Dockerfile") rollcallDir := filepath.Join(repoRoot, "examples", "rollcall") - spikeBuildImage(t, rollcallDir, "nullclaw:latest", "Dockerfile.nullclaw-base") + spikeBuildImage(t, rollcallDir, "nanobot:latest", "Dockerfile.nanobot-base") spikeWriteFile(t, filepath.Join(workDir, "AGENTS.md"), "# Digest Spike Agent\n\nUse runtime channel context.") - spikeWriteFile(t, filepath.Join(workDir, "Clawfile"), `FROM nullclaw:latest + spikeWriteFile(t, filepath.Join(workDir, "Clawfile"), `FROM nanobot:latest -CLAW_TYPE nullclaw +CLAW_TYPE nanobot AGENT AGENTS.md MODEL primary openai/gpt-4o HANDLE discord diff --git a/cmd/claw/spike_mixed_managed_test.go b/cmd/claw/spike_mixed_managed_test.go index 7a17780..dd5cece 100644 --- a/cmd/claw/spike_mixed_managed_test.go +++ b/cmd/claw/spike_mixed_managed_test.go @@ -27,15 +27,15 @@ func TestSpikeMixedManagedTypesCoexist(t *testing.T) { repoRoot := filepath.Join(filepath.Dir(thisFile), "..", "..") openFixture := filepath.Join(repoRoot, "testdata", "openclaw-stub") - microFixture := filepath.Join(repoRoot, "testdata", "microclaw-stub") + nanoFixture := filepath.Join(repoRoot, "testdata", "nanobot-stub") openTag := fmt.Sprintf("claw-spike-openclaw:%d", time.Now().UnixNano()) - microTag := fmt.Sprintf("claw-spike-microclaw:%d", time.Now().UnixNano()) + nanoTag := fmt.Sprintf("claw-spike-nanobot:%d", time.Now().UnixNano()) spikeBuildImage(t, openFixture, openTag, "Clawfile") - spikeBuildImage(t, microFixture, microTag, "Clawfile") + spikeBuildImage(t, nanoFixture, nanoTag, "Clawfile") t.Cleanup(func() { - _, _ = exec.Command("docker", "image", "rm", "-f", openTag, microTag).CombinedOutput() + _, _ = exec.Command("docker", "image", "rm", "-f", openTag, nanoTag).CombinedOutput() }) spikeEnsureRepoInfraImages(t, repoRoot, infraComponentClawdash) @@ -48,8 +48,8 @@ func TestSpikeMixedManagedTypesCoexist(t *testing.T) { if err := os.WriteFile(filepath.Join(agentsDir, "OPEN.md"), []byte("# Open Agent\n\nYou are open."), 0o644); err != nil { t.Fatalf("write OPEN.md: %v", err) } - if err := os.WriteFile(filepath.Join(agentsDir, "MICRO.md"), []byte("# Micro Agent\n\nYou are micro."), 0o644); err != nil { - t.Fatalf("write MICRO.md: %v", err) + if err := os.WriteFile(filepath.Join(agentsDir, "NANO.md"), []byte("# Nano Agent\n\nYou are nano."), 0o644); err != nil { + t.Fatalf("write NANO.md: %v", err) } podPath := filepath.Join(workDir, "claw-pod.yml") @@ -62,13 +62,13 @@ services: x-claw: agent: ./agents/OPEN.md - micro: + nano: image: %s x-claw: - agent: ./agents/MICRO.md + agent: ./agents/NANO.md environment: ANTHROPIC_API_KEY: sk-spike-anthropic -`, openTag, microTag) +`, openTag, nanoTag) if err := os.WriteFile(podPath, []byte(podYAML), 0o644); err != nil { t.Fatalf("write pod file: %v", err) } @@ -92,28 +92,27 @@ services: if _, err := os.Stat(openConfig); err != nil { t.Fatalf("openclaw config not generated: %v", err) } - microConfig := filepath.Join(workDir, ".claw-runtime", "micro", "config", "microclaw.config.yaml") - if _, err := os.Stat(microConfig); err != nil { - t.Fatalf("microclaw config not generated: %v", err) + nanoConfig := filepath.Join(workDir, ".claw-runtime", "nano", "nanobot-home", "config.json") + if _, err := os.Stat(nanoConfig); err != nil { + t.Fatalf("nanobot config not generated: %v", err) } - microSeed := filepath.Join(workDir, ".claw-runtime", "micro", "data", "runtime", "groups", "AGENTS.md") - if _, err := os.Stat(microSeed); err != nil { - t.Fatalf("microclaw seeded AGENTS.md not generated: %v", err) + nanoSeed := filepath.Join(workDir, ".claw-runtime", "nano", "nanobot-home", "workspace", "AGENTS.md") + if _, err := os.Stat(nanoSeed); err != nil { + t.Fatalf("nanobot seeded AGENTS.md not generated: %v", err) } - microBytes, err := os.ReadFile(microConfig) + nanoBytes, err := os.ReadFile(nanoConfig) if err != nil { - t.Fatalf("read microclaw config: %v", err) + t.Fatalf("read nanobot config: %v", err) } - var micro map[string]interface{} - if err := yaml.Unmarshal(microBytes, µ); err != nil { - t.Fatalf("parse microclaw config yaml: %v", err) + var nano map[string]interface{} + if err := yaml.Unmarshal(nanoBytes, &nano); err != nil { + t.Fatalf("parse nanobot config json: %v", err) } - if got := micro["llm_provider"]; got != "anthropic" { - t.Fatalf("expected microclaw llm_provider=anthropic, got %v", got) - } - if got := micro["model"]; got != "claude-sonnet-4" { - t.Fatalf("expected microclaw model=claude-sonnet-4, got %v", got) + agents, _ := nano["agents"].(map[string]interface{}) + defaults, _ := agents["defaults"].(map[string]interface{}) + if got := defaults["model"]; got != "anthropic/claude-sonnet-4" { + t.Fatalf("expected nanobot agents.defaults.model=anthropic/claude-sonnet-4, got %v", got) } composeBytes, err := os.ReadFile(composePath) @@ -142,18 +141,21 @@ services: t.Fatalf("unexpected open OPENCLAW_CONFIG_PATH: %q", openSvc.Environment["OPENCLAW_CONFIG_PATH"]) } - microSvc, ok := compose.Services["micro"] + nanoSvc, ok := compose.Services["nano"] if !ok { - t.Fatalf("compose missing micro service") + t.Fatalf("compose missing nano service") + } + if !nanoSvc.ReadOnly { + t.Fatalf("expected nano service to remain read_only") } - if microSvc.ReadOnly { - t.Fatalf("expected micro service to be writable") + if nanoSvc.Environment["CLAW_MANAGED"] != "true" { + t.Fatalf("unexpected nano CLAW_MANAGED: %q", nanoSvc.Environment["CLAW_MANAGED"]) } - if microSvc.Environment["MICROCLAW_CONFIG"] != "/app/config/microclaw.config.yaml" { - t.Fatalf("unexpected micro MICROCLAW_CONFIG: %q", microSvc.Environment["MICROCLAW_CONFIG"]) + if nanoSvc.Environment["HOME"] != "/root" { + t.Fatalf("unexpected nano HOME: %q", nanoSvc.Environment["HOME"]) } - for _, service := range []string{"open", "micro"} { + for _, service := range []string{"open", "nano"} { out, err := exec.Command("docker", "compose", "-f", composePath, "ps", "-q", service).Output() if err != nil { t.Fatalf("docker compose ps %s: %v", service, err) diff --git a/cmd/claw/spike_policy_test.go b/cmd/claw/spike_policy_test.go index ceca08a..565921b 100644 --- a/cmd/claw/spike_policy_test.go +++ b/cmd/claw/spike_policy_test.go @@ -54,7 +54,7 @@ func TestSpikePolicyEvaluatorGeneratedPod(t *testing.T) { agentImage := fmt.Sprintf("policy-spike-agent:%d", time.Now().UnixNano()) spikeEnsurePulledImage(t, pythonImage) rollcallDir := filepath.Join(repoRoot, "examples", "rollcall") - spikeBuildImage(t, rollcallDir, "nullclaw:latest", "Dockerfile.nullclaw-base") + spikeBuildImage(t, rollcallDir, "nanobot:latest", "Dockerfile.nanobot-base") spikeEnsureRepoInfraImages(t, repoRoot, infraComponentClawdash) spikeEnsureCllamaPassthroughImage(t, repoRoot) t.Cleanup(func() { @@ -62,9 +62,9 @@ func TestSpikePolicyEvaluatorGeneratedPod(t *testing.T) { }) spikeWriteFile(t, filepath.Join(workDir, "AGENTS.md"), "# Policy Spike Agent\n\nUse the configured model.") - spikeWriteFile(t, filepath.Join(workDir, "Clawfile"), `FROM nullclaw:latest + spikeWriteFile(t, filepath.Join(workDir, "Clawfile"), `FROM nanobot:latest -CLAW_TYPE nullclaw +CLAW_TYPE nanobot AGENT AGENTS.md MODEL primary openai/gpt-4o `) diff --git a/cmd/claw/spike_rollcall_test.go b/cmd/claw/spike_rollcall_test.go index bdc1a8e..8659687 100644 --- a/cmd/claw/spike_rollcall_test.go +++ b/cmd/claw/spike_rollcall_test.go @@ -107,9 +107,6 @@ func TestSpikeRollCall(t *testing.T) { alwaysRebuild bool // true for stubs that embed discord-responder.sh }{ {"openclaw:latest", "Dockerfile.openclaw-base", "", true}, - {"nullclaw:latest", "Dockerfile.nullclaw-base", "", true}, - {"microclaw:latest", "Dockerfile.microclaw-base", "", true}, - {"nanoclaw-orchestrator:latest", "Dockerfile.nanoclaw-base", "", true}, {"nanobot:latest", "Dockerfile.nanobot-base", "", true}, {"picoclaw:latest", "Dockerfile.picoclaw-base", "", true}, // Hermes is a real runtime — build from the canonical dockerfiles dir so @@ -136,9 +133,6 @@ func TestSpikeRollCall(t *testing.T) { dockerfile string }{ {"rollcall-openclaw:latest", "agents/oc-roll/Clawfile"}, - {"rollcall-nullclaw:latest", "agents/nc-roll/Clawfile"}, - {"rollcall-microclaw:latest", "agents/mc-roll/Clawfile"}, - {"rollcall-nanoclaw:latest", "agents/nano-roll/Clawfile"}, {"rollcall-nanobot:latest", "agents/nb-roll/Clawfile"}, {"rollcall-picoclaw:latest", "agents/pc-roll/Clawfile"}, {"rollcall-hermes:latest", "agents/hm-roll/Clawfile"}, @@ -186,40 +180,20 @@ func TestSpikeRollCall(t *testing.T) { requireKeys: []string{"GEMINI_API_KEY"}, }, { - // Stubs send the bare provider/model ref directly via curl, so we - // must use a model name that Anthropic actually recognises today + // nb-roll carries the anthropic-messages ingress surface for the + // default run. The retired nullclaw/nanoclaw stubs used to cover + // it (ADR-026); pc-roll also exercises it but is gated behind + // CLAW_SPIKE_ENABLE_PICOCLAW while #137 is open. Stubs send the + // bare provider/model ref directly via curl, so we must use a + // model name that Anthropic actually recognises today // (claude-sonnet-4 alone is no longer a valid alias upstream). - name: "nc-roll", - runtime: "nullclaw", - proxyFormat: "anthropic", - proxyModel: "anthropic/claude-sonnet-4-6", - expectedSurface: "anthropic-messages", - requireKeys: []string{"ANTHROPIC_API_KEY"}, - }, - { - name: "mc-roll", - runtime: "microclaw", - proxyFormat: "openai", - proxyModel: "openrouter/anthropic/claude-sonnet-4", - expectedSurface: "openai-chat-completions", - requireKeys: []string{"OPENROUTER_API_KEY"}, - }, - { - name: "nano-roll", - runtime: "nanoclaw", + name: "nb-roll", + runtime: "nanobot", proxyFormat: "anthropic", proxyModel: "anthropic/claude-sonnet-4-6", expectedSurface: "anthropic-messages", requireKeys: []string{"ANTHROPIC_API_KEY"}, }, - { - name: "nb-roll", - runtime: "nanobot", - proxyFormat: "openai", - proxyModel: "openrouter/anthropic/claude-sonnet-4", - expectedSurface: "openai-chat-completions", - requireKeys: []string{"OPENROUTER_API_KEY"}, - }, { // pc-roll is currently broken upstream — picoclaw's gateway binary // rejects a port=0 config pre-check that the clawdapus picoclaw @@ -633,12 +607,7 @@ func rollcallResolveContainerID(t *testing.T, composePath, serviceName string) s } func rollcallExpectedRuntimeKeywords(runtime string) []string { - switch strings.ToLower(runtime) { - case "nanoclaw": - return []string{"nanoclaw", "claude agent"} - default: - return []string{strings.ToLower(runtime)} - } + return []string{strings.ToLower(runtime)} } func rollcallWaitForRuntimeResponse(t *testing.T, token, channelID, afterMessageID string, wantKeywords []string, timeout time.Duration) string { diff --git a/cmd/claw/spike_test.go b/cmd/claw/spike_test.go index 999af0f..4d30cd3 100644 --- a/cmd/claw/spike_test.go +++ b/cmd/claw/spike_test.go @@ -64,9 +64,7 @@ func TestSpikeComposeUp(t *testing.T) { } for _, key := range []string{ "MOMENTUM_TRADER_BOT_TOKEN", - "SYSTEMS_MONITOR_BOT_TOKEN", "VALUE_TRADER_BOT_TOKEN", - "MICRO_BOT_TOKEN", "HERMES_BOT_TOKEN", } { if env[key] == "" { @@ -76,9 +74,7 @@ func TestSpikeComposeUp(t *testing.T) { requiredIDs := []string{ "DESK_MANAGER_DISCORD_ID", "MOMENTUM_TRADER_DISCORD_ID", - "SYSTEMS_MONITOR_DISCORD_ID", "VALUE_TRADER_DISCORD_ID", - "MICRO_DISCORD_ID", "HERMES_DISCORD_ID", "DISCORD_GUILD_ID", "DISCORD_TRADING_FLOOR_CHANNEL", @@ -100,14 +96,8 @@ func TestSpikeComposeUp(t *testing.T) { if !spikeImageExists("openclaw:latest") { spikeBuildImage(t, dir, "openclaw:latest", "Dockerfile.openclaw-base") } - if !spikeImageExists("nanoclaw-orchestrator:latest") { - rollcallDir := filepath.Join(repoRoot, "examples", "rollcall") - spikeBuildImage(t, rollcallDir, "nanoclaw-orchestrator:latest", "Dockerfile.nanoclaw-base") - } spikeBuildImage(t, dir, "trading-desk:latest", "Clawfile") - spikeBuildImage(t, dir, "trading-desk-nanoclaw:latest", "Clawfile.nanoclaw") - spikeBuildImage(t, dir, "trading-desk-nullclaw:latest", "Clawfile.nullclaw") - spikeBuildImage(t, dir, "trading-desk-microclaw:latest", "Clawfile.microclaw") + spikeBuildImage(t, dir, "trading-desk-nanobot:latest", "Clawfile.nanobot") spikeBuildImage(t, dir, "trading-desk-hermes:latest", "Clawfile.hermes") spikeBuildImage(t, dir, "trading-api:latest", "Dockerfile.trading-api") spikeEnsureRepoInfraImages(t, repoRoot, infraComponentClawAPI, infraComponentClawdash, infraComponentClawWall) @@ -158,7 +148,7 @@ func TestSpikeComposeUp(t *testing.T) { if err != nil { t.Fatalf("parse expanded spike pod: %v", err) } - for _, svcName := range []string{"desk-manager", "momentum-trader", "systems-monitor", "value-trader", "micro", "hermes"} { + for _, svcName := range []string{"desk-manager", "momentum-trader", "value-trader", "hermes"} { svc := parsedPod.Services[svcName] if svc == nil || svc.Claw == nil { t.Fatalf("parsed pod: missing claw service %q", svcName) @@ -200,7 +190,7 @@ func TestSpikeComposeUp(t *testing.T) { // teardown runs the compose down and dumps logs. teardown := func() { - for _, svc := range []string{"desk-manager", "momentum-trader", "systems-monitor", "value-trader", "micro", "hermes", "trading-api"} { + for _, svc := range []string{"desk-manager", "momentum-trader", "value-trader", "hermes", "trading-api"} { name := fmt.Sprintf("trading-desk-%s-1", svc) out, _ := exec.Command("docker", "logs", "--tail", "100", name).CombinedOutput() t.Logf("=== %s logs ===\n%s", name, string(out)) @@ -385,7 +375,7 @@ func TestSpikeComposeUp(t *testing.T) { // ── Verify cllama context artifacts ───────────────────────────────────── - for _, agent := range []string{"desk-manager", "momentum-trader", "systems-monitor", "value-trader", "micro", "hermes"} { + for _, agent := range []string{"desk-manager", "momentum-trader", "value-trader", "hermes"} { agentDir := filepath.Join(runtimeDir, "context", agent) for _, rel := range []string{"AGENTS.md", "CLAWDAPUS.md", "metadata.json"} { if _, err := os.Stat(filepath.Join(agentDir, rel)); err != nil { @@ -457,161 +447,48 @@ func TestSpikeComposeUp(t *testing.T) { healthOut, _ := exec.Command("docker", "exec", containerName, "openclaw", "health", "--json").Output() t.Logf("openclaw health --json: %s", strings.TrimSpace(string(healthOut))) - // ── Verify Systems-Monitor (nanoclaw orchestrator) container artifacts ─── - - systemsMonitorContainer := spikeContainerName("systems-monitor") - spikeWaitRunning(t, systemsMonitorContainer, 45*time.Second) - - // Combined CLAUDE.md at /workspace/groups/main/CLAUDE.md (agent contract + CLAWDAPUS.md) - systemsMonitorClaude, errA := exec.Command("docker", "exec", systemsMonitorContainer, "cat", "/workspace/groups/main/CLAUDE.md").Output() - if errA != nil { - t.Errorf("systems-monitor: docker exec cat /workspace/groups/main/CLAUDE.md: %v", errA) - } else { - claudeStr := string(systemsMonitorClaude) - if !strings.Contains(claudeStr, "Systems-Monitor") { - t.Errorf("systems-monitor: CLAUDE.md doesn't mention Systems-Monitor: %q", claudeStr[:min(200, len(claudeStr))]) - } - if !strings.Contains(claudeStr, "trading-desk") { - t.Errorf("systems-monitor: CLAUDE.md doesn't reference pod name 'trading-desk'") - } - t.Logf("systems-monitor CLAUDE.md: %d bytes", len(systemsMonitorClaude)) - } - - // Docker socket must be mounted - systemsMonitorSock, errS := exec.Command("docker", "exec", systemsMonitorContainer, "ls", "-la", "/var/run/docker.sock").Output() - if errS != nil { - t.Errorf("systems-monitor: Docker socket not mounted at /var/run/docker.sock: %v", errS) - } else { - t.Logf("systems-monitor docker.sock: %s", strings.TrimSpace(string(systemsMonitorSock))) - } - - // Skills must use directory layout at orchestrator path - systemsMonitorSkills, errSk := exec.Command("docker", "exec", systemsMonitorContainer, "find", "/workspace/container/skills", "-name", "SKILL.md").Output() - if errSk != nil { - t.Errorf("systems-monitor: failed to list skills at /workspace/container/skills: %v", errSk) - } else if strings.TrimSpace(string(systemsMonitorSkills)) == "" { - t.Error("systems-monitor: no SKILL.md files found in /workspace/container/skills/") - } else { - t.Logf("systems-monitor skills (directory layout):\n%s", strings.TrimSpace(string(systemsMonitorSkills))) - } + // ── Verify Value-Trader (nanobot) artifacts ────────────────────────────── - // .env file with cllama bearer token (orchestrator's readEnvFile passes to agent-runners) - systemsMonitorEnvFile, errEF := exec.Command("docker", "exec", systemsMonitorContainer, "cat", "/workspace/.env").Output() - if errEF != nil { - t.Errorf("systems-monitor: .env not mounted at /workspace/.env: %v", errEF) - } else if !strings.Contains(string(systemsMonitorEnvFile), "ANTHROPIC_API_KEY=") { - t.Errorf("systems-monitor: .env should contain ANTHROPIC_API_KEY, got %q", string(systemsMonitorEnvFile)) - } else { - t.Logf("systems-monitor .env: %d bytes", len(systemsMonitorEnvFile)) - } - - // Verify compose.generated.yml has nanoclaw-specific markers - if !strings.Contains(composeSrc, "/var/run/docker.sock") { - t.Error("compose.generated.yml: expected Docker socket mount for nanoclaw service") - } - if !strings.Contains(composeSrc, "ANTHROPIC_BASE_URL") { - t.Error("compose.generated.yml: expected ANTHROPIC_BASE_URL for nanoclaw cllama wiring") - } - if !strings.Contains(composeSrc, "/app/config/microclaw.config.yaml") { - t.Error("compose.generated.yml: expected microclaw config mount") - } - if !strings.Contains(composeSrc, "/root/.hermes") { - t.Error("compose.generated.yml: expected hermes home mount") - } - if !strings.Contains(composeSrc, "HERMES_HOME") { - t.Error("compose.generated.yml: expected HERMES_HOME env for hermes service") - } - - // ANTHROPIC_BASE_URL env var points to cllama proxy - systemsMonitorEnvOut, errE := exec.Command("docker", "exec", systemsMonitorContainer, "printenv", "ANTHROPIC_BASE_URL").Output() - if errE != nil { - t.Errorf("systems-monitor: ANTHROPIC_BASE_URL not set: %v", errE) - } else { - systemsMonitorBaseURL := strings.TrimSpace(string(systemsMonitorEnvOut)) - if !strings.Contains(systemsMonitorBaseURL, "cllama") { - t.Errorf("systems-monitor: ANTHROPIC_BASE_URL should point to cllama proxy, got %q", systemsMonitorBaseURL) - } else { - t.Logf("systems-monitor ANTHROPIC_BASE_URL: %s", systemsMonitorBaseURL) - } - } - - // CLAW_NETWORK env var is set for agent-runner pod connectivity - systemsMonitorNetwork, errN := exec.Command("docker", "exec", systemsMonitorContainer, "printenv", "CLAW_NETWORK").Output() - if errN != nil { - t.Errorf("systems-monitor: CLAW_NETWORK not set: %v", errN) - } else { - t.Logf("systems-monitor CLAW_NETWORK: %s", strings.TrimSpace(string(systemsMonitorNetwork))) - } - - // ── Verify Value-Trader (nullclaw) artifacts ───────────────────────────── - - valueTraderConfigPath := filepath.Join(runtimeDir, "value-trader", "nullclaw-home", "config.json") + valueTraderConfigPath := filepath.Join(runtimeDir, "value-trader", "nanobot-home", "config.json") valueTraderConfigData := spikeReadFile(t, valueTraderConfigPath) var valueTraderCfg map[string]interface{} if err := json.Unmarshal([]byte(valueTraderConfigData), &valueTraderCfg); err != nil { - t.Fatalf("parse value-trader nullclaw config.json: %v", err) + t.Fatalf("parse value-trader nanobot config.json: %v", err) } - valueTraderChannels, ok := valueTraderCfg["channels"].(map[string]interface{}) + valueTraderAgents, ok := valueTraderCfg["agents"].(map[string]interface{}) if !ok { - t.Fatalf("value-trader config.json: missing channels object") + t.Fatalf("value-trader config.json: missing agents object") } - valueTraderDiscord, ok := valueTraderChannels["discord"].(map[string]interface{}) + valueTraderDefaults, ok := valueTraderAgents["defaults"].(map[string]interface{}) if !ok { - t.Fatalf("value-trader config.json: missing channels.discord object") + t.Fatalf("value-trader config.json: missing agents.defaults object") } - valueTraderAccounts, ok := valueTraderDiscord["accounts"].(map[string]interface{}) - if !ok { - t.Fatalf("value-trader config.json: missing channels.discord.accounts object") - } - valueTraderMainAccount, ok := valueTraderAccounts["main"].(map[string]interface{}) - if !ok { - t.Fatalf("value-trader config.json: missing channels.discord.accounts.main object") + if got, _ := valueTraderDefaults["model"].(string); got != "anthropic/claude-sonnet-4" { + t.Errorf("value-trader config.json: expected agents.defaults.model=anthropic/claude-sonnet-4, got %v", valueTraderDefaults["model"]) } - if tok, _ := valueTraderMainAccount["token"].(string); tok == "" { - t.Errorf("value-trader config.json: expected channels.discord.accounts.main.token to be set") - } - if gid, _ := valueTraderMainAccount["guild_id"].(string); gid != env["DISCORD_GUILD_ID"] { - t.Errorf("value-trader config.json: expected guild_id=%q, got %q", env["DISCORD_GUILD_ID"], gid) - } - if models, ok := valueTraderCfg["models"].(map[string]interface{}); ok { - if providers, ok := models["providers"].(map[string]interface{}); ok { - if anthropic, ok := providers["anthropic"].(map[string]interface{}); ok { - if got := anthropic["base_url"]; got != "http://cllama-passthrough:8080/v1" { - t.Errorf("value-trader config.json: expected anthropic.base_url=http://cllama-passthrough:8080/v1, got %v", got) - } + if providers, ok := valueTraderCfg["providers"].(map[string]interface{}); ok { + if anthropic, ok := providers["anthropic"].(map[string]interface{}); ok { + if got, _ := anthropic["base_url"].(string); !strings.Contains(got, "cllama") { + t.Errorf("value-trader config.json: expected providers.anthropic.base_url to point at cllama, got %v", anthropic["base_url"]) } + } else { + t.Errorf("value-trader config.json: missing providers.anthropic for cllama wiring") } + } else { + t.Errorf("value-trader config.json: missing providers object") } - valueTraderContainer := spikeContainerName("value-trader") - spikeWaitHealthy(t, valueTraderContainer, 60*time.Second) - if out, err := exec.Command("docker", "exec", valueTraderContainer, "cat", "/root/.nullclaw/config.json").CombinedOutput(); err != nil { - t.Errorf("value-trader: expected /root/.nullclaw/config.json in container: %v (%s)", err, strings.TrimSpace(string(out))) - } - - // ── Verify Micro (microclaw) artifacts ─────────────────────────────────── - - microConfigPath := filepath.Join(runtimeDir, "micro", "config", "microclaw.config.yaml") - microConfigData := spikeReadFile(t, microConfigPath) - var microCfg map[string]interface{} - if err := yaml.Unmarshal([]byte(microConfigData), µCfg); err != nil { - t.Fatalf("parse microclaw.config.yaml: %v", err) - } - if got := microCfg["llm_provider"]; got != "anthropic" { - t.Errorf("microclaw.config.yaml: expected llm_provider=anthropic, got %v", got) - } - if got := microCfg["model"]; got != "claude-sonnet-4" { - t.Errorf("microclaw.config.yaml: expected model=claude-sonnet-4, got %v", got) - } - if got := microCfg["llm_base_url"]; got != "http://cllama:8080/v1" { - t.Errorf("microclaw.config.yaml: expected llm_base_url=http://cllama:8080/v1, got %v", got) + valueTraderSeedPath := filepath.Join(runtimeDir, "value-trader", "nanobot-home", "workspace", "AGENTS.md") + valueTraderSeed := spikeReadFile(t, valueTraderSeedPath) + if !strings.Contains(valueTraderSeed, "Value-Trader") { + t.Errorf("value-trader seeded AGENTS.md doesn't mention Value-Trader") } - microContainer := spikeContainerName("micro") - spikeWaitHealthy(t, microContainer, 60*time.Second) - if out, err := exec.Command("docker", "exec", microContainer, "test", "-f", "/app/config/microclaw.config.yaml").CombinedOutput(); err != nil { - t.Errorf("micro: expected /app/config/microclaw.config.yaml in container: %v (%s)", err, strings.TrimSpace(string(out))) + valueTraderContainer := spikeContainerName("value-trader") + spikeWaitHealthy(t, valueTraderContainer, 60*time.Second) + if out, err := exec.Command("docker", "exec", valueTraderContainer, "cat", "/root/.nanobot/config.json").CombinedOutput(); err != nil { + t.Errorf("value-trader: expected /root/.nanobot/config.json in container: %v (%s)", err, strings.TrimSpace(string(out))) } // ── Verify Hermes artifacts ────────────────────────────────────────────── @@ -685,7 +562,6 @@ func TestSpikeComposeUp(t *testing.T) { spikeVerifyDiscordGreeting(t, env["DESK_MANAGER_BOT_TOKEN"], channelID, "desk-manager online", 10*time.Second) spikeVerifyDiscordGreeting(t, env["MOMENTUM_TRADER_BOT_TOKEN"], channelID, "momentum-trader online", 10*time.Second) spikeVerifyDiscordGreeting(t, env["DESK_MANAGER_BOT_TOKEN"], channelID, "value-trader online", 15*time.Second) - spikeVerifyDiscordGreeting(t, env["DESK_MANAGER_BOT_TOKEN"], channelID, "microclaw online", 15*time.Second) spikeVerifyDiscordGreeting(t, env["DESK_MANAGER_BOT_TOKEN"], channelID, "hermes online", 15*time.Second) // trading-api posts its own startup message to Discord via webhook — this @@ -696,8 +572,8 @@ func TestSpikeComposeUp(t *testing.T) { // The startup message must contain Discord mentions for openclaw agents. // CLAW_HANDLE_* vars are broadcast to all pod services by claw, so trading-api // picks up the agent IDs and includes <@ID> mentions in its webhook message. - // Note: Systems-Monitor (nanoclaw) has no greeting mechanism — the mock_server.py only - // formats mentions for agents it knows about (desk-manager, momentum-trader). + // Note: the mock_server.py only formats mentions for agents it knows about + // (desk-manager, momentum-trader). if deskManagerID := env["DESK_MANAGER_DISCORD_ID"]; deskManagerID != "" { spikeVerifyDiscordGreeting(t, env["DESK_MANAGER_BOT_TOKEN"], channelID, "<@"+deskManagerID+">", 5*time.Second) } @@ -871,9 +747,6 @@ func spikeTagRunnerBaseImage(t *testing.T, tag string) { func spikeIsRunnerBaseTag(tag string) bool { switch tag { case "openclaw:latest", - "nullclaw:latest", - "microclaw:latest", - "nanoclaw-orchestrator:latest", "nanobot:latest", "picoclaw:latest": return true diff --git a/cmd/claw/trading_desk_contracts_test.go b/cmd/claw/trading_desk_contracts_test.go index b772039..9bfa814 100644 --- a/cmd/claw/trading_desk_contracts_test.go +++ b/cmd/claw/trading_desk_contracts_test.go @@ -10,7 +10,6 @@ import ( func TestTradingDeskContractsDoNotContainInstructionPlaceholders(t *testing.T) { agentDir := filepath.Join("..", "..", "examples", "trading-desk", "agents") files := []string{ - "SYSTEMS-MONITOR.md", "SOCIAL-TRADER.md", "NEWS-ROUTER.md", "MACRO-TRADER.md", diff --git a/examples/rollcall/README.md b/examples/rollcall/README.md index ed34e68..5264a24 100644 --- a/examples/rollcall/README.md +++ b/examples/rollcall/README.md @@ -2,7 +2,7 @@ End-to-end driver parity fixture for Clawdapus. -This fixture reuses one Discord bot identity across seven runtime families, so +This fixture reuses one Discord bot identity across four runtime families, so the spike now materializes one runtime at a time rather than pretending to be a concurrent social-topology test. Each subtest wires one runtime through `cllama` passthrough, exposes `clawdash`, posts a Discord mention, and verifies @@ -11,9 +11,6 @@ that the runtime replies identifying itself. ## What It Covers - `openclaw` -- `nullclaw` -- `microclaw` -- `nanoclaw` - `nanobot` - `picoclaw` - `hermes` @@ -25,7 +22,7 @@ conformance only. It is not a valid concurrent topology example. ## Files -- `claw-pod.yml`: spike template containing the seven runtime service definitions +- `claw-pod.yml`: spike template containing the four runtime service definitions - `agents/*/Clawfile`: one Clawfile per runtime - `agents/*/AGENTS.md`: minimal runtime-specific self-identification contract - `Dockerfile.*-base`: local base images used by the spike test @@ -76,15 +73,12 @@ go test -tags spike -v -run TestSpikeCapabilityWaveLive ./cmd/claw/... The test should: 1. Build the base images for each runtime family if needed. -2. Build the seven rollcall agent images. +2. Build the four rollcall agent images. 3. Materialize and run one single-service pod per runtime. 4. Wait for each runtime container to become healthy or running. 5. Post a Discord mention through the webhook for that runtime. -6. Observe seven AI-generated replies across the full test run mentioning: +6. Observe four AI-generated replies across the full test run mentioning: - `openclaw` - - `nullclaw` - - `microclaw` - - `nanoclaw` (or `Claude Agent SDK`) - `nanobot` - `picoclaw` - `hermes` diff --git a/examples/rollcall/agents/nb-roll/Clawfile b/examples/rollcall/agents/nb-roll/Clawfile index 84eee49..a8c91f4 100644 --- a/examples/rollcall/agents/nb-roll/Clawfile +++ b/examples/rollcall/agents/nb-roll/Clawfile @@ -2,5 +2,5 @@ FROM nanobot:latest CLAW_TYPE nanobot AGENT AGENTS.md -MODEL primary openrouter/anthropic/claude-sonnet-4 +MODEL primary anthropic/claude-sonnet-4 HANDLE discord diff --git a/examples/rollcall/claw-pod.yml b/examples/rollcall/claw-pod.yml index 17aa5b8..e8570c7 100644 --- a/examples/rollcall/claw-pod.yml +++ b/examples/rollcall/claw-pod.yml @@ -43,84 +43,6 @@ services: ROLLCALL_CHANNEL_ID: "${ROLLCALL_CHANNEL_ID}" CLAW_RUNTIME: "openclaw" - nc-roll: - image: rollcall-nullclaw:latest - build: - context: . - dockerfile: agents/nc-roll/Clawfile - x-claw: - agent: ./agents/nc-roll/AGENTS.md - cllama: passthrough - cllama-env: - ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY}" - handles: - discord: - id: "${DISCORD_BOT_ID}" - username: "nc-roll" - guilds: - - id: "${DISCORD_GUILD_ID}" - name: "Roll Call" - channels: - - id: "${ROLLCALL_CHANNEL_ID}" - name: roll-call - environment: - DISCORD_BOT_TOKEN: "${DISCORD_BOT_TOKEN}" - DISCORD_BOT_ID: "${DISCORD_BOT_ID}" - ROLLCALL_CHANNEL_ID: "${ROLLCALL_CHANNEL_ID}" - CLAW_RUNTIME: "nullclaw" - - mc-roll: - image: rollcall-microclaw:latest - build: - context: . - dockerfile: agents/mc-roll/Clawfile - x-claw: - agent: ./agents/mc-roll/AGENTS.md - cllama: passthrough - cllama-env: - ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY}" - handles: - discord: - id: "${DISCORD_BOT_ID}" - username: "mc-roll" - guilds: - - id: "${DISCORD_GUILD_ID}" - name: "Roll Call" - channels: - - id: "${ROLLCALL_CHANNEL_ID}" - name: roll-call - environment: - DISCORD_BOT_TOKEN: "${DISCORD_BOT_TOKEN}" - DISCORD_BOT_ID: "${DISCORD_BOT_ID}" - ROLLCALL_CHANNEL_ID: "${ROLLCALL_CHANNEL_ID}" - CLAW_RUNTIME: "microclaw" - - nano-roll: - image: rollcall-nanoclaw:latest - build: - context: . - dockerfile: agents/nano-roll/Clawfile - x-claw: - agent: ./agents/nano-roll/AGENTS.md - cllama: passthrough - cllama-env: - ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY}" - handles: - discord: - id: "${DISCORD_BOT_ID}" - username: "nano-roll" - guilds: - - id: "${DISCORD_GUILD_ID}" - name: "Roll Call" - channels: - - id: "${ROLLCALL_CHANNEL_ID}" - name: roll-call - environment: - DISCORD_BOT_TOKEN: "${DISCORD_BOT_TOKEN}" - DISCORD_BOT_ID: "${DISCORD_BOT_ID}" - ROLLCALL_CHANNEL_ID: "${ROLLCALL_CHANNEL_ID}" - CLAW_RUNTIME: "nanoclaw" - nb-roll: image: rollcall-nanobot:latest build: @@ -129,8 +51,10 @@ services: x-claw: agent: ./agents/nb-roll/AGENTS.md cllama: passthrough + # nb-roll carries the anthropic-messages ingress surface now that the + # nullclaw/nanoclaw stubs are retired (ADR-026). cllama-env: - OPENROUTER_API_KEY: "${OPENROUTER_API_KEY}" + ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY}" handles: discord: id: "${DISCORD_BOT_ID}" diff --git a/examples/trading-desk/.env.example b/examples/trading-desk/.env.example index 4ed2f2a..ddaf5e5 100644 --- a/examples/trading-desk/.env.example +++ b/examples/trading-desk/.env.example @@ -4,14 +4,10 @@ export DESK_MANAGER_DISCORD_ID=desk_manager_discord_id export MOMENTUM_TRADER_BOT_TOKEN=momentum_trader_bot_token export MOMENTUM_TRADER_DISCORD_ID=momentum_trader_discord_id -export SYSTEMS_MONITOR_BOT_TOKEN=systems_monitor_bot_token -export SYSTEMS_MONITOR_DISCORD_ID=systems_monitor_discord_id export VALUE_TRADER_BOT_TOKEN=value_trader_bot_token export VALUE_TRADER_DISCORD_ID=value_trader_discord_id -export MICRO_BOT_TOKEN=micro_bot_token -export MICRO_DISCORD_ID=micro_discord_id export HERMES_BOT_TOKEN=hermes_bot_token export HERMES_DISCORD_ID=hermes_discord_id diff --git a/examples/trading-desk/Clawfile.nanobot b/examples/trading-desk/Clawfile.nanobot new file mode 100644 index 0000000..8364f3a --- /dev/null +++ b/examples/trading-desk/Clawfile.nanobot @@ -0,0 +1,16 @@ +FROM alpine:3.20 + +CLAW_TYPE nanobot +AGENT AGENTS.md + +MODEL primary anthropic/claude-sonnet-4 + +HANDLE discord + +RUN apk add --no-cache bash curl jq tini procps +COPY nanobot /usr/local/bin/nanobot +RUN chmod +x /usr/local/bin/nanobot +COPY nanobot-entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +ENTRYPOINT ["/sbin/tini", "--", "/app/entrypoint.sh"] diff --git a/examples/trading-desk/README.md b/examples/trading-desk/README.md index 7c578c2..c2931c6 100644 --- a/examples/trading-desk/README.md +++ b/examples/trading-desk/README.md @@ -1,6 +1,6 @@ # Trading Desk Example -A reference pod showing mixed managed agents (`openclaw`, `nanoclaw`, `nullclaw`, `microclaw`, `hermes`) running as a governed fleet — each with Discord presence and shared infrastructure context. +A reference pod showing mixed managed agents (`openclaw`, `nanobot`, `hermes`) running as a governed fleet — each with Discord presence and shared infrastructure context. This spike now enables cllama passthrough routing for trader agents (`cllama`). ## What's in the box @@ -8,9 +8,7 @@ This spike now enables cllama passthrough routing for trader agents (`cllama`). | File | Purpose | |------|---------| | `Clawfile` | OpenClaw trader image (desk-manager, momentum-trader) | -| `Clawfile.nanoclaw` | NanoClaw image (systems-monitor) | -| `Clawfile.nullclaw` | NullClaw image with Discord startup announcement (value-trader) | -| `Clawfile.microclaw` | MicroClaw spike image with Discord startup announcement | +| `Clawfile.nanobot` | Nanobot spike image with Discord startup announcement (value-trader) | | `Clawfile.hermes` | Hermes spike image with Discord startup announcement | | `claw-pod.yml` | Pod definition — per-agent handles, surfaces, invoke schedules | | `Dockerfile.openclaw-base` | Builds the local `openclaw:latest` base image | @@ -32,16 +30,12 @@ Copy `.env.example` and fill in real values: ``` DESK_MANAGER_BOT_TOKEN=Bot ... MOMENTUM_TRADER_BOT_TOKEN=Bot ... -SYSTEMS_MONITOR_BOT_TOKEN=Bot ... VALUE_TRADER_BOT_TOKEN=Bot ... -MICRO_BOT_TOKEN=Bot ... HERMES_BOT_TOKEN=Bot ... DESK_MANAGER_DISCORD_ID=... MOMENTUM_TRADER_DISCORD_ID=... -SYSTEMS_MONITOR_DISCORD_ID=... VALUE_TRADER_DISCORD_ID=... -MICRO_DISCORD_ID=... HERMES_DISCORD_ID=... DISCORD_GUILD_ID=... DISCORD_TRADING_FLOOR_CHANNEL=... @@ -84,7 +78,7 @@ claw logs desk-manager **Requirements:** - Docker running -- Env-owned Discord IDs for desk-manager, momentum-trader, systems-monitor, value-trader, micro, and hermes, plus shared guild/channel IDs +- Env-owned Discord IDs for desk-manager, momentum-trader, value-trader, and hermes, plus shared guild/channel IDs - Real bot tokens in `examples/trading-desk/.env` (`DESK_MANAGER_BOT_TOKEN` at minimum; others can reuse it for local spike runs) - Provider key for proxy env (`OPENROUTER_API_KEY`; `ANTHROPIC_API_KEY` optional fallback) - Internet access from Docker containers (no internal-only Docker Desktop network mode) diff --git a/examples/trading-desk/claw-pod.yml b/examples/trading-desk/claw-pod.yml index 58ff2f9..91457e3 100644 --- a/examples/trading-desk/claw-pod.yml +++ b/examples/trading-desk/claw-pod.yml @@ -30,15 +30,15 @@ x-trading-desk: - ./policy/approval-workflow.md # ───────────────────────────────────────────────────────────────────────────── -# Trading Desk Pod (desk-manager + momentum-trader + systems-monitor + value-trader + micro + hermes) +# Trading Desk Pod (desk-manager + momentum-trader + value-trader + hermes) # -# Mixed-driver pod: openclaw + nanoclaw + nullclaw + microclaw + hermes agents +# Mixed-driver pod: openclaw + nanobot + hermes agents # coordinating via Discord and a shared research volume. # # Required environment variables (.env or Docker secrets): # POSTGRES_PASSWORD, SECRET_KEY_BASE -# DESK_MANAGER_BOT_TOKEN, MOMENTUM_TRADER_BOT_TOKEN, SYSTEMS_MONITOR_BOT_TOKEN, VALUE_TRADER_BOT_TOKEN, MICRO_BOT_TOKEN, HERMES_BOT_TOKEN -# DESK_MANAGER_DISCORD_ID, MOMENTUM_TRADER_DISCORD_ID, SYSTEMS_MONITOR_DISCORD_ID, VALUE_TRADER_DISCORD_ID, MICRO_DISCORD_ID, HERMES_DISCORD_ID +# DESK_MANAGER_BOT_TOKEN, MOMENTUM_TRADER_BOT_TOKEN, VALUE_TRADER_BOT_TOKEN, HERMES_BOT_TOKEN +# DESK_MANAGER_DISCORD_ID, MOMENTUM_TRADER_DISCORD_ID, VALUE_TRADER_DISCORD_ID, HERMES_DISCORD_ID # DISCORD_GUILD_ID, DISCORD_TRADING_FLOOR_CHANNEL # OPENROUTER_API_KEY, ANTHROPIC_API_KEY (proxy-side provider keys for cllama) # Note: claw resolves ${...} in x-claw metadata from the pod-local .env before @@ -163,31 +163,13 @@ services: CLAW_GREETING_CHANNEL: "${DISCORD_TRADING_FLOOR_CHANNEL}" CLAW_GREETING_MESSAGE: "momentum-trader online." - # ── Systems-Monitor — Systems Monitor (NanoClaw) ────────────────────────── - - systems-monitor: - image: trading-desk-nanoclaw:latest - build: - context: . - dockerfile: Clawfile.nanoclaw - x-claw: - <<: *cllama_agent - agent: ./agents/SYSTEMS-MONITOR.md - handles: - discord: - id: "${SYSTEMS_MONITOR_DISCORD_ID}" - username: "systems-monitor" - surfaces: *shared_trading_surfaces - environment: - DISCORD_BOT_TOKEN: "${SYSTEMS_MONITOR_BOT_TOKEN}" - - # ── Value-Trader — Counterparty Sanity Check (NullClaw) ─────────────────── + # ── Value-Trader — Counterparty Sanity Check (Nanobot) ──────────────────── value-trader: - image: trading-desk-nullclaw:latest + image: trading-desk-nanobot:latest build: context: . - dockerfile: Clawfile.nullclaw + dockerfile: Clawfile.nanobot x-claw: <<: *cllama_agent agent: ./agents/VALUE-TRADER.md @@ -202,26 +184,6 @@ services: CLAW_GREETING_CHANNEL: "${DISCORD_TRADING_FLOOR_CHANNEL}" CLAW_GREETING_MESSAGE: "value-trader online." - # ── Micro — Operations Monitor (MicroClaw) ─────────────────────────────── - - micro: - image: trading-desk-microclaw:latest - build: - context: . - dockerfile: Clawfile.microclaw - x-claw: - <<: *cllama_agent - agent: ./agents/MICRO.md - handles: - discord: - id: "${MICRO_DISCORD_ID}" - username: "micro" - surfaces: *shared_trading_surfaces - environment: - DISCORD_BOT_TOKEN: "${MICRO_BOT_TOKEN}" - CLAW_GREETING_CHANNEL: "${DISCORD_TRADING_FLOOR_CHANNEL}" - CLAW_GREETING_MESSAGE: "microclaw online." - # ── Hermes — Desk Communications (Hermes) ───────────────────────────────── hermes: diff --git a/examples/trading-desk/nanobot b/examples/trading-desk/nanobot new file mode 100755 index 0000000..d707637 --- /dev/null +++ b/examples/trading-desk/nanobot @@ -0,0 +1,41 @@ +#!/bin/sh +set -eu + +cmd="${1:-gateway}" + +case "$cmd" in + gateway) + # Stub nanobot gateway: stay alive so the driver healthcheck + # (pgrep -f 'nanobot gateway') passes. + echo "[nanobot-stub] gateway running (pid $$)" >&2 + while true; do + sleep 3600 + done + ;; + cron) + sub="${2:-}" + case "$sub" in + list) + jobs_file=/tmp/nanobot-spike/cron.jobs + count=0 + if [ -f "$jobs_file" ]; then + count="$(wc -l <"$jobs_file" | tr -d ' ')" + fi + echo "info(cron): Scheduled jobs (${count}):" + ;; + add) + mkdir -p /tmp/nanobot-spike + printf "%s\t%s\n" "${3:-}" "${4:-}" >>/tmp/nanobot-spike/cron.jobs + echo "info(cron): added schedule '${3:-}'" + ;; + *) + echo "error: unknown nanobot cron subcommand '$sub'" >&2 + exit 2 + ;; + esac + ;; + *) + echo "error: unknown nanobot subcommand '$cmd'" >&2 + exit 2 + ;; +esac diff --git a/examples/trading-desk/nanobot-entrypoint.sh b/examples/trading-desk/nanobot-entrypoint.sh new file mode 100755 index 0000000..e37887b --- /dev/null +++ b/examples/trading-desk/nanobot-entrypoint.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -e + +send_greeting() { + if [ -z "${DISCORD_BOT_TOKEN:-}" ] || [ -z "${CLAW_GREETING_CHANNEL:-}" ] || [ -z "${CLAW_GREETING_MESSAGE:-}" ]; then + return 0 + fi + + payload="$(jq -n --arg content "${CLAW_GREETING_MESSAGE}" '{content: $content}')" + i=0 + while [ "$i" -lt 5 ]; do + if curl -fsS -X POST "https://discord.com/api/v10/channels/${CLAW_GREETING_CHANNEL}/messages" \ + -H "Authorization: Bot ${DISCORD_BOT_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$payload" >/dev/null; then + echo "[nanobot-entrypoint] greeting sent" + return 0 + fi + i=$((i + 1)) + sleep 3 + done + + echo "[nanobot-entrypoint] warning: failed to send greeting after retries" +} + +send_greeting +exec nanobot gateway diff --git a/internal/build/build.go b/internal/build/build.go index 7d3c933..0f5387d 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -14,10 +14,7 @@ import ( "github.com/mostlydev/clawdapus/internal/clawfile" "github.com/mostlydev/clawdapus/internal/driver" _ "github.com/mostlydev/clawdapus/internal/driver/hermes" - _ "github.com/mostlydev/clawdapus/internal/driver/microclaw" _ "github.com/mostlydev/clawdapus/internal/driver/nanobot" - _ "github.com/mostlydev/clawdapus/internal/driver/nanoclaw" - _ "github.com/mostlydev/clawdapus/internal/driver/nullclaw" _ "github.com/mostlydev/clawdapus/internal/driver/openclaw" // register built-in drivers for build-time validation _ "github.com/mostlydev/clawdapus/internal/driver/picoclaw" ) diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 6c1f89d..6866ae7 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -75,34 +75,6 @@ AGENT CONTRACT.md } } -func TestGenerateAcceptsMicroclawType(t *testing.T) { - dir := t.TempDir() - clawfilePath := filepath.Join(dir, "Clawfile") - - input := `FROM alpine:latest - -CLAW_TYPE microclaw -AGENT AGENTS.md -MODEL primary anthropic/claude-sonnet-4 -` - if err := os.WriteFile(clawfilePath, []byte(input), 0o644); err != nil { - t.Fatal(err) - } - - generatedPath, err := Generate(clawfilePath) - if err != nil { - t.Fatalf("expected microclaw CLAW_TYPE to be accepted, got error: %v", err) - } - - content, err := os.ReadFile(generatedPath) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(content), `LABEL claw.type="microclaw"`) { - t.Fatal("missing claw.type=microclaw label in generated output") - } -} - func TestGenerateAcceptsNanobotType(t *testing.T) { dir := t.TempDir() clawfilePath := filepath.Join(dir, "Clawfile") diff --git a/internal/driver/microclaw/baseimage.go b/internal/driver/microclaw/baseimage.go deleted file mode 100644 index 0cbb537..0000000 --- a/internal/driver/microclaw/baseimage.go +++ /dev/null @@ -1,23 +0,0 @@ -package microclaw - -const baseImageTag = "microclaw:latest" - -const baseImageDockerfile = `FROM ghcr.io/microclaw/microclaw:latest - -USER root - -RUN apt-get update && apt-get install -y --no-install-recommends procps \ - && rm -rf /var/lib/apt/lists/* - -RUN mkdir -p /app/config /claw-data - -USER microclaw -` - -func (d *Driver) BaseImage() (string, string) { - return baseImageTag, baseImageDockerfile -} - -func (d *Driver) RunnerAlias() string { - return "microclaw" -} diff --git a/internal/driver/microclaw/baseimage_test.go b/internal/driver/microclaw/baseimage_test.go deleted file mode 100644 index 959c9ae..0000000 --- a/internal/driver/microclaw/baseimage_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package microclaw - -import ( - "strings" - "testing" - - "github.com/mostlydev/clawdapus/internal/driver" -) - -func TestBaseImageProvider(t *testing.T) { - d := &Driver{} - - var _ driver.BaseImageProvider = d - - tag, dockerfile := d.BaseImage() - - if tag != "microclaw:latest" { - t.Fatalf("expected tag microclaw:latest, got %q", tag) - } - if !strings.HasPrefix(dockerfile, "FROM ghcr.io/microclaw/microclaw:latest") { - t.Fatal("Dockerfile should start from the official microclaw image") - } - if !strings.Contains(dockerfile, "apt-get install -y --no-install-recommends procps") { - t.Fatal("Dockerfile should install procps for the pgrep-based healthcheck") - } - if !strings.Contains(dockerfile, "mkdir -p /app/config /claw-data") { - t.Fatal("Dockerfile should create the config mount parent path") - } -} diff --git a/internal/driver/microclaw/driver.go b/internal/driver/microclaw/driver.go deleted file mode 100644 index f5d5085..0000000 --- a/internal/driver/microclaw/driver.go +++ /dev/null @@ -1,487 +0,0 @@ -package microclaw - -import ( - "context" - "fmt" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "time" - - "github.com/docker/docker/client" - "github.com/mostlydev/clawdapus/internal/cllama" - "github.com/mostlydev/clawdapus/internal/driver" - "github.com/mostlydev/clawdapus/internal/driver/shared" - "gopkg.in/yaml.v3" -) - -// Driver implements Clawdapus runtime materialization for MicroClaw. -type Driver struct{} - -func init() { - driver.Register("microclaw", &Driver{}) -} - -func (d *Driver) Validate(rc *driver.ResolvedClaw) error { - if rc.AgentHostPath == "" { - return fmt.Errorf("microclaw driver: no agent host path specified (no contract, no start)") - } - if _, err := os.Stat(rc.AgentHostPath); err != nil { - return fmt.Errorf("microclaw driver: agent file %q not found: %w", rc.AgentHostPath, err) - } - - modelRef, err := shared.PrimaryModelRef(rc.Models) - if err != nil { - return fmt.Errorf("microclaw driver: %w", err) - } - provider, _, ok := shared.SplitModelRef(modelRef) - if !ok { - return fmt.Errorf("microclaw driver: invalid MODEL primary %q (expected provider/model)", modelRef) - } - - for _, cmd := range rc.Configures { - if _, _, err := parseConfigSetCommand(cmd); err != nil { - return fmt.Errorf("microclaw driver: unsupported CONFIGURE command %q: %w", cmd, err) - } - } - - for platform := range rc.Handles { - switch platform { - case "discord": - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "DISCORD_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return fmt.Errorf("microclaw driver: DISCORD_BOT_TOKEN: %w", err) - } - if token == "" { - return fmt.Errorf("microclaw driver: HANDLE discord requires DISCORD_BOT_TOKEN in service environment") - } - case "telegram": - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "TELEGRAM_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return fmt.Errorf("microclaw driver: TELEGRAM_BOT_TOKEN: %w", err) - } - if token == "" { - return fmt.Errorf("microclaw driver: HANDLE telegram requires TELEGRAM_BOT_TOKEN in service environment") - } - case "slack": - botToken, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "SLACK_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return fmt.Errorf("microclaw driver: SLACK_BOT_TOKEN: %w", err) - } - appToken, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "SLACK_APP_TOKEN", rc.RuntimeEnv) - if err != nil { - return fmt.Errorf("microclaw driver: SLACK_APP_TOKEN: %w", err) - } - if botToken == "" || appToken == "" { - return fmt.Errorf("microclaw driver: HANDLE slack requires SLACK_BOT_TOKEN and SLACK_APP_TOKEN in service environment") - } - default: - fmt.Printf("[claw] warning: microclaw driver has no HANDLE mapping for platform %q; skipping channel enablement\n", platform) - } - } - - if len(rc.Cllama) == 0 { - llmProvider := shared.NormalizeProvider(provider) - if !shared.ProviderAllowsEmptyAPIKey(llmProvider) { - key, err := shared.ResolveProviderAPIKeyWithRuntimeEnv(llmProvider, rc.Environment, rc.RuntimeEnv) - if err != nil { - return fmt.Errorf("microclaw driver: provider %q API key: %w", llmProvider, err) - } - if key == "" { - expected := strings.Join(shared.ExpectedProviderKeys(llmProvider), ", ") - return fmt.Errorf("microclaw driver: no API key found for provider %q (checked: %s)", llmProvider, expected) - } - } - } - - if len(rc.Invocations) > 0 { - fmt.Printf("[claw] warning: microclaw driver: INVOKE scheduling not supported; ignoring %d invocations\n", len(rc.Invocations)) - } - - return nil -} - -func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpts) (*driver.MaterializeResult, error) { - podName := opts.PodName - if podName == "" { - podName = rc.ServiceName - } - memoryDir, err := shared.PreparePortableMemory(shared.ResolveStateDir(opts.RuntimeDir, opts.StateDir), opts.RuntimeDir) - if err != nil { - return nil, fmt.Errorf("microclaw driver: prepare portable memory: %w", err) - } - - cfg, err := generateConfig(rc) - if err != nil { - return nil, err - } - - for _, cmd := range rc.Configures { - path, value, err := parseConfigSetCommand(cmd) - if err != nil { - return nil, fmt.Errorf("microclaw driver: apply CONFIGURE %q: %w", cmd, err) - } - if err := shared.SetPath(cfg, path, value); err != nil { - return nil, fmt.Errorf("microclaw driver: apply CONFIGURE %q: %w", cmd, err) - } - } - - cfgBytes, err := yaml.Marshal(cfg) - if err != nil { - return nil, fmt.Errorf("microclaw driver: marshal config yaml: %w", err) - } - - configDir := filepath.Join(opts.RuntimeDir, "config") - if err := os.MkdirAll(configDir, 0o777); err != nil { - return nil, fmt.Errorf("microclaw driver: create config dir: %w", err) - } - configPath := filepath.Join(configDir, "microclaw.config.yaml") - if err := os.WriteFile(configPath, cfgBytes, 0o644); err != nil { - return nil, fmt.Errorf("microclaw driver: write config: %w", err) - } - - dataDir := filepath.Join(opts.RuntimeDir, "data") - groupsDir := filepath.Join(dataDir, "runtime", "groups") - if err := os.MkdirAll(groupsDir, 0o777); err != nil { - return nil, fmt.Errorf("microclaw driver: create runtime groups dir: %w", err) - } - if err := os.MkdirAll(filepath.Join(dataDir, "skills"), 0o777); err != nil { - return nil, fmt.Errorf("microclaw driver: create skills dir: %w", err) - } - if err := os.MkdirAll(filepath.Join(dataDir, "working_dir"), 0o777); err != nil { - return nil, fmt.Errorf("microclaw driver: create working_dir: %w", err) - } - - agentContent, err := os.ReadFile(rc.AgentHostPath) - if err != nil { - return nil, fmt.Errorf("microclaw driver: read agent contract: %w", err) - } - clawdapusMd := shared.GenerateClawdapusMD(rc, podName) - seededAgents := strings.TrimSpace(string(agentContent)) + "\n\n---\n\n" + strings.TrimSpace(clawdapusMd) + "\n" - seedPath := filepath.Join(groupsDir, "AGENTS.md") - if err := os.WriteFile(seedPath, []byte(seededAgents), 0o644); err != nil { - return nil, fmt.Errorf("microclaw driver: write seeded AGENTS.md: %w", err) - } - - mounts := []driver.Mount{ - { - HostPath: configPath, - ContainerPath: "/app/config/microclaw.config.yaml", - ReadOnly: true, - }, - { - HostPath: dataDir, - ContainerPath: "/claw-data", - ReadOnly: false, - }, - { - HostPath: memoryDir, - ContainerPath: shared.PortableMemoryDir, - ReadOnly: false, - }, - } - if rc.PersonaHostPath != "" { - mounts = append(mounts, driver.Mount{ - HostPath: rc.PersonaHostPath, - ContainerPath: "/claw-data/persona", - ReadOnly: false, - }) - } - - env := map[string]string{ - "CLAW_MANAGED": "true", - shared.PortableMemoryEnv: shared.PortableMemoryDir, - "MICROCLAW_CONFIG": "/app/config/microclaw.config.yaml", - } - if rc.PersonaHostPath != "" { - env["CLAW_PERSONA_DIR"] = "/claw-data/persona" - } - - return &driver.MaterializeResult{ - Mounts: mounts, - Tmpfs: []string{"/tmp"}, - ReadOnly: false, - Restart: "on-failure", - User: "microclaw", - SkillDir: "/claw-data/skills", - SkillLayout: "directory", - Healthcheck: &driver.Healthcheck{ - Test: []string{"CMD-SHELL", "pgrep -f 'microclaw' > /dev/null"}, - Interval: "30s", - Timeout: "10s", - Retries: 3, - }, - Environment: env, - }, nil -} - -func (d *Driver) PostApply(rc *driver.ResolvedClaw, opts driver.PostApplyOpts) error { - if opts.ContainerID == "" { - return fmt.Errorf("microclaw driver: post-apply check failed: no container ID") - } - cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) - if err != nil { - return fmt.Errorf("microclaw driver: post-apply docker client: %w", err) - } - defer cli.Close() - - info, err := cli.ContainerInspect(context.Background(), opts.ContainerID) - if err != nil { - return fmt.Errorf("microclaw driver: post-apply inspect: %w", err) - } - if info.State == nil || !info.State.Running { - status := "unknown" - if info.State != nil { - status = info.State.Status - } - return fmt.Errorf("microclaw driver: container is not running (status: %s)", status) - } - return nil -} - -func (d *Driver) HealthProbe(ref driver.ContainerRef) (*driver.Health, error) { - if ref.ContainerID == "" { - return &driver.Health{OK: false, Detail: "no container ID"}, nil - } - cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) - if err != nil { - return nil, fmt.Errorf("microclaw driver: health docker client: %w", err) - } - defer cli.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - info, err := cli.ContainerInspect(ctx, ref.ContainerID) - if err != nil { - return &driver.Health{OK: false, Detail: fmt.Sprintf("inspect: %v", err)}, nil - } - if info.State == nil || !info.State.Running { - status := "unknown" - if info.State != nil { - status = info.State.Status - } - return &driver.Health{OK: false, Detail: fmt.Sprintf("not running (%s)", status)}, nil - } - return &driver.Health{OK: true, Detail: "container running"}, nil -} - -func generateConfig(rc *driver.ResolvedClaw) (map[string]interface{}, error) { - modelRef, err := shared.PrimaryModelRef(rc.Models) - if err != nil { - return nil, fmt.Errorf("microclaw driver: %w", err) - } - provider, modelID, ok := shared.SplitModelRef(modelRef) - if !ok { - return nil, fmt.Errorf("microclaw driver: invalid MODEL primary %q (expected provider/model)", modelRef) - } - - cfg := map[string]interface{}{ - "data_dir": "/claw-data", - "skills_dir": "/claw-data/skills", - "working_dir": "/claw-data/working_dir", - "working_dir_isolation": "chat", - "timezone": resolvedTimezone(rc.Timezone), - "web_enabled": true, - "web_host": "127.0.0.1", - "web_port": 10961, - } - - channels := map[string]interface{}{ - "web": map[string]interface{}{"enabled": true}, - } - - if len(rc.Cllama) > 0 { - if strings.TrimSpace(rc.CllamaToken) == "" { - return nil, fmt.Errorf("microclaw driver: CLLAMA is enabled but token is empty") - } - firstProxy := cllama.ProxyBaseURL(rc.Cllama[0]) - cfg["llm_base_url"] = firstProxy - cfg["api_key"] = rc.CllamaToken - - if shared.NormalizeProvider(provider) == "anthropic" { - cfg["llm_provider"] = "anthropic" - cfg["model"] = modelID - } else { - cfg["llm_provider"] = "openai" - cfg["model"] = provider + "/" + modelID - } - } else { - llmProvider := shared.NormalizeProvider(provider) - cfg["llm_provider"] = llmProvider - cfg["model"] = modelID - apiKey, err := shared.ResolveProviderAPIKeyWithRuntimeEnv(llmProvider, rc.Environment, rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: provider %q api_key: %w", llmProvider, err) - } - if apiKey != "" { - cfg["api_key"] = apiKey - } else { - cfg["api_key"] = "" - } - } - - platforms := make([]string, 0, len(rc.Handles)) - for platform := range rc.Handles { - platforms = append(platforms, platform) - } - sort.Strings(platforms) - - for _, platform := range platforms { - h := rc.Handles[platform] - switch platform { - case "discord": - discord := map[string]interface{}{"enabled": true, "mention_only": true} - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "DISCORD_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: HANDLE discord: DISCORD_BOT_TOKEN: %w", err) - } - if token != "" { - discord["bot_token"] = token - } - if h != nil && strings.TrimSpace(h.Username) != "" { - discord["bot_username"] = h.Username - } - if allowed := discordAllowedChannels(h); len(allowed) > 0 { - discord["allowed_channels"] = allowed - } - channels["discord"] = discord - case "telegram": - telegram := map[string]interface{}{"enabled": true} - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "TELEGRAM_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: HANDLE telegram: TELEGRAM_BOT_TOKEN: %w", err) - } - if token != "" { - telegram["bot_token"] = token - } - if h != nil && strings.TrimSpace(h.Username) != "" { - telegram["bot_username"] = h.Username - } - if allowed := telegramAllowedGroups(h); len(allowed) > 0 { - telegram["allowed_groups"] = allowed - } - channels["telegram"] = telegram - case "slack": - slack := map[string]interface{}{"enabled": true} - bot, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "SLACK_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: SLACK_BOT_TOKEN: %w", err) - } - if bot != "" { - slack["bot_token"] = bot - } - app, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "SLACK_APP_TOKEN", rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: SLACK_APP_TOKEN: %w", err) - } - if app != "" { - slack["app_token"] = app - } - if allowed := slackAllowedChannels(h); len(allowed) > 0 { - slack["allowed_channels"] = allowed - } - channels["slack"] = slack - } - } - - cfg["channels"] = channels - return cfg, nil -} - -func resolvedTimezone(raw string) string { - timezone := strings.TrimSpace(raw) - if timezone == "" { - return "UTC" - } - return timezone -} - -func discordAllowedChannels(h *driver.HandleInfo) []uint64 { - if h == nil { - return nil - } - seen := map[uint64]struct{}{} - out := make([]uint64, 0) - for _, g := range h.Guilds { - for _, ch := range g.Channels { - id, err := strconv.ParseUint(strings.TrimSpace(ch.ID), 10, 64) - if err != nil || id == 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - } - sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) - return out -} - -func telegramAllowedGroups(h *driver.HandleInfo) []int64 { - if h == nil { - return nil - } - seen := map[int64]struct{}{} - out := make([]int64, 0) - for _, g := range h.Guilds { - id, err := strconv.ParseInt(strings.TrimSpace(g.ID), 10, 64) - if err != nil || id == 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) - return out -} - -func slackAllowedChannels(h *driver.HandleInfo) []string { - if h == nil { - return nil - } - seen := map[string]struct{}{} - out := make([]string, 0) - for _, g := range h.Guilds { - for _, ch := range g.Channels { - id := strings.TrimSpace(ch.ID) - if id == "" { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - } - sort.Strings(out) - return out -} - -func parseConfigSetCommand(cmd string) (string, interface{}, error) { - parts := strings.Fields(cmd) - if len(parts) < 5 || parts[0] != "microclaw" || parts[1] != "config" || parts[2] != "set" { - return "", nil, fmt.Errorf("expected 'microclaw config set '") - } - path := parts[3] - value := strings.TrimSpace(strings.Join(parts[4:], " ")) - if value == "" { - return "", nil, fmt.Errorf("empty config value") - } - - var typed interface{} - if err := yaml.Unmarshal([]byte(value), &typed); err == nil { - if typed != nil { - return path, typed, nil - } - } - return path, value, nil -} diff --git a/internal/driver/microclaw/driver_test.go b/internal/driver/microclaw/driver_test.go deleted file mode 100644 index 4c6e7f7..0000000 --- a/internal/driver/microclaw/driver_test.go +++ /dev/null @@ -1,308 +0,0 @@ -package microclaw - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/mostlydev/clawdapus/internal/driver" - "github.com/mostlydev/clawdapus/internal/driver/shared" - "gopkg.in/yaml.v3" -) - -func TestDriverRegistered(t *testing.T) { - d, err := driver.Lookup("microclaw") - if err != nil { - t.Fatalf("microclaw driver not registered: %v", err) - } - if d == nil { - t.Fatal("microclaw driver is nil") - } -} - -func TestValidateRequiresAgentPath(t *testing.T) { - d := &Driver{} - rc := &driver.ResolvedClaw{ServiceName: "mc", Models: map[string]string{"primary": "anthropic/claude-sonnet-4"}} - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for missing agent host path") - } - if !strings.Contains(err.Error(), "no agent host path") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestValidateRequiresPrimaryModel(t *testing.T) { - rc, _ := newTestRC(t) - rc.Models = map[string]string{} - - d := &Driver{} - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for missing primary model") - } - if !strings.Contains(err.Error(), "MODEL primary") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestValidateDiscordHandleRequiresToken(t *testing.T) { - rc, _ := newTestRC(t) - rc.Handles = map[string]*driver.HandleInfo{"discord": {ID: "123"}} - rc.Environment = map[string]string{} - - d := &Driver{} - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for missing DISCORD_BOT_TOKEN") - } - if !strings.Contains(err.Error(), "DISCORD_BOT_TOKEN") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestValidateAcceptsBasicConfig(t *testing.T) { - rc, _ := newTestRC(t) - rc.Environment["ANTHROPIC_API_KEY"] = "sk-ant" - - d := &Driver{} - if err := d.Validate(rc); err != nil { - t.Fatalf("unexpected validation error: %v", err) - } -} - -func TestMaterializeWritesConfigAndSeededMemory(t *testing.T) { - rc, tmp := newTestRC(t) - rc.Models = map[string]string{"primary": "openrouter/anthropic/claude-sonnet-4"} - rc.Environment["OPENROUTER_API_KEY"] = "or-key" - rc.Timezone = "America/New_York" - - runtimeDir := filepath.Join(tmp, "runtime") - if err := os.MkdirAll(runtimeDir, 0o700); err != nil { - t.Fatal(err) - } - - d := &Driver{} - result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: runtimeDir, PodName: "mixed-pod"}) - if err != nil { - t.Fatalf("Materialize failed: %v", err) - } - - if result.ReadOnly { - t.Fatal("microclaw should run with writable filesystem") - } - if result.SkillDir != "/claw-data/skills" { - t.Fatalf("unexpected skill dir: %q", result.SkillDir) - } - if result.SkillLayout != "directory" { - t.Fatalf("unexpected skill layout: %q", result.SkillLayout) - } - if result.User != "microclaw" { - t.Fatalf("unexpected user: %q", result.User) - } - if result.Environment["MICROCLAW_CONFIG"] != "/app/config/microclaw.config.yaml" { - t.Fatalf("expected MICROCLAW_CONFIG env, got %q", result.Environment["MICROCLAW_CONFIG"]) - } - if result.Environment[shared.PortableMemoryEnv] != shared.PortableMemoryDir { - t.Fatalf("expected %s=%s, got %q", shared.PortableMemoryEnv, shared.PortableMemoryDir, result.Environment[shared.PortableMemoryEnv]) - } - - foundMemoryMount := false - for _, mount := range result.Mounts { - if mount.ContainerPath == shared.PortableMemoryDir { - foundMemoryMount = true - if mount.ReadOnly { - t.Fatal("portable memory mount should be writable") - } - } - } - if !foundMemoryMount { - t.Fatal("expected portable memory mount") - } - - cfgPath := filepath.Join(runtimeDir, "config", "microclaw.config.yaml") - cfgBytes, err := os.ReadFile(cfgPath) - if err != nil { - t.Fatalf("read generated config: %v", err) - } - var cfg map[string]interface{} - if err := yaml.Unmarshal(cfgBytes, &cfg); err != nil { - t.Fatalf("parse generated config yaml: %v", err) - } - if got := cfg["llm_provider"]; got != "openrouter" { - t.Fatalf("expected llm_provider=openrouter, got %v", got) - } - if got := cfg["model"]; got != "anthropic/claude-sonnet-4" { - t.Fatalf("expected model=anthropic/claude-sonnet-4, got %v", got) - } - if got := cfg["api_key"]; got != "or-key" { - t.Fatalf("expected api_key=or-key, got %v", got) - } - if got := cfg["data_dir"]; got != "/claw-data" { - t.Fatalf("expected data_dir=/claw-data, got %v", got) - } - if got := cfg["timezone"]; got != "America/New_York" { - t.Fatalf("expected timezone=America/New_York, got %v", got) - } - - channels, _ := cfg["channels"].(map[string]interface{}) - web, _ := channels["web"].(map[string]interface{}) - if enabled, _ := web["enabled"].(bool); !enabled { - t.Fatal("expected channels.web.enabled=true") - } - - seedPath := filepath.Join(runtimeDir, "data", "runtime", "groups", "AGENTS.md") - seeded, err := os.ReadFile(seedPath) - if err != nil { - t.Fatalf("read seeded AGENTS.md: %v", err) - } - seedStr := string(seeded) - if !strings.Contains(seedStr, "You are micro") { - t.Fatalf("seeded AGENTS.md missing agent contract content") - } - if !strings.Contains(seedStr, "mixed-pod") { - t.Fatalf("seeded AGENTS.md missing pod context") - } -} - -func TestMaterializeCllamaOpenAIModelRewrite(t *testing.T) { - rc, tmp := newTestRC(t) - rc.Models = map[string]string{"primary": "openrouter/moonshotai/kimi-k2.5"} - rc.Cllama = []string{"passthrough"} - rc.CllamaToken = "agent:token" - - runtimeDir := filepath.Join(tmp, "runtime") - if err := os.MkdirAll(runtimeDir, 0o700); err != nil { - t.Fatal(err) - } - - d := &Driver{} - if _, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: runtimeDir}); err != nil { - t.Fatalf("Materialize failed: %v", err) - } - - cfgPath := filepath.Join(runtimeDir, "config", "microclaw.config.yaml") - cfgBytes, err := os.ReadFile(cfgPath) - if err != nil { - t.Fatalf("read generated config: %v", err) - } - var cfg map[string]interface{} - if err := yaml.Unmarshal(cfgBytes, &cfg); err != nil { - t.Fatalf("parse generated config yaml: %v", err) - } - - if got := cfg["llm_provider"]; got != "openai" { - t.Fatalf("expected cllama llm_provider=openai, got %v", got) - } - if got := cfg["model"]; got != "openrouter/moonshotai/kimi-k2.5" { - t.Fatalf("expected cllama model provider-prefixed, got %v", got) - } - if got := cfg["llm_base_url"]; got != "http://cllama:8080/v1" { - t.Fatalf("expected cllama llm_base_url rewrite, got %v", got) - } - if got := cfg["api_key"]; got != "agent:token" { - t.Fatalf("expected cllama token api_key, got %v", got) - } -} - -func TestMaterializeCllamaAnthropicModelRewrite(t *testing.T) { - rc, tmp := newTestRC(t) - rc.Models = map[string]string{"primary": "anthropic/claude-sonnet-4"} - rc.Cllama = []string{"passthrough"} - rc.CllamaToken = "agent:token" - - runtimeDir := filepath.Join(tmp, "runtime") - if err := os.MkdirAll(runtimeDir, 0o700); err != nil { - t.Fatal(err) - } - - d := &Driver{} - if _, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: runtimeDir}); err != nil { - t.Fatalf("Materialize failed: %v", err) - } - - cfgPath := filepath.Join(runtimeDir, "config", "microclaw.config.yaml") - cfgBytes, err := os.ReadFile(cfgPath) - if err != nil { - t.Fatalf("read generated config: %v", err) - } - var cfg map[string]interface{} - if err := yaml.Unmarshal(cfgBytes, &cfg); err != nil { - t.Fatalf("parse generated config yaml: %v", err) - } - - if got := cfg["llm_provider"]; got != "anthropic" { - t.Fatalf("expected anthropic provider for anthropic model under cllama, got %v", got) - } - if got := cfg["model"]; got != "claude-sonnet-4" { - t.Fatalf("expected de-prefixed anthropic model, got %v", got) - } -} - -func TestMaterializeDiscordHandleChannelConfig(t *testing.T) { - rc, tmp := newTestRC(t) - rc.Environment["OPENROUTER_API_KEY"] = "or-key" - rc.Environment["DISCORD_BOT_TOKEN"] = "discord-token" - rc.Models = map[string]string{"primary": "openrouter/anthropic/claude-sonnet-4"} - rc.Handles = map[string]*driver.HandleInfo{ - "discord": { - ID: "111", - Username: "micro-bot", - Guilds: []driver.GuildInfo{{ - ID: "999", - Channels: []driver.ChannelInfo{{ID: "123456789012345678", Name: "trading-floor"}}, - }}, - }, - } - - runtimeDir := filepath.Join(tmp, "runtime") - if err := os.MkdirAll(runtimeDir, 0o700); err != nil { - t.Fatal(err) - } - - d := &Driver{} - if _, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: runtimeDir}); err != nil { - t.Fatalf("Materialize failed: %v", err) - } - - cfgPath := filepath.Join(runtimeDir, "config", "microclaw.config.yaml") - cfgBytes, err := os.ReadFile(cfgPath) - if err != nil { - t.Fatalf("read generated config: %v", err) - } - var cfg map[string]interface{} - if err := yaml.Unmarshal(cfgBytes, &cfg); err != nil { - t.Fatalf("parse generated config yaml: %v", err) - } - - channels, _ := cfg["channels"].(map[string]interface{}) - discord, _ := channels["discord"].(map[string]interface{}) - if enabled, _ := discord["enabled"].(bool); !enabled { - t.Fatal("expected channels.discord.enabled=true") - } - if got := discord["bot_token"]; got != "discord-token" { - t.Fatalf("expected discord bot token in config, got %v", got) - } - if got := discord["bot_username"]; got != "micro-bot" { - t.Fatalf("expected discord bot_username from HANDLE username, got %v", got) - } -} - -func newTestRC(t *testing.T) (*driver.ResolvedClaw, string) { - t.Helper() - tmp := t.TempDir() - agentPath := filepath.Join(tmp, "AGENTS.md") - if err := os.WriteFile(agentPath, []byte("# Agent\n\nYou are micro."), 0o644); err != nil { - t.Fatal(err) - } - - rc := &driver.ResolvedClaw{ - ServiceName: "micro", - ClawType: "microclaw", - AgentHostPath: agentPath, - Models: map[string]string{"primary": "anthropic/claude-sonnet-4"}, - Environment: map[string]string{}, - } - return rc, tmp -} diff --git a/internal/driver/nanoclaw/baseimage.go b/internal/driver/nanoclaw/baseimage.go deleted file mode 100644 index c77fff2..0000000 --- a/internal/driver/nanoclaw/baseimage.go +++ /dev/null @@ -1,44 +0,0 @@ -package nanoclaw - -const baseImageTag = "nanoclaw-orchestrator:latest" - -const baseImageDockerfile = `FROM node:22-bookworm-slim AS builder - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates git python3 make g++ \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /src - -RUN git clone --depth 1 https://github.com/qwibitai/nanoclaw.git /src \ - && rm -rf /src/.git - -RUN npm ci -RUN npm run build - -FROM node:22-bookworm-slim - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates git procps tini \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=docker:27-cli /usr/local/bin/docker /usr/local/bin/docker - -RUN npm install -g @anthropic-ai/claude-code - -WORKDIR /workspace - -COPY --from=builder /src /workspace - -RUN mkdir -p /workspace/groups/main /workspace/container/skills - -ENTRYPOINT ["/usr/bin/tini", "--", "node", "/workspace/dist/index.js"] -` - -func (d *Driver) BaseImage() (string, string) { - return baseImageTag, baseImageDockerfile -} - -func (d *Driver) RunnerAlias() string { - return "nanoclaw-orchestrator" -} diff --git a/internal/driver/nanoclaw/baseimage_test.go b/internal/driver/nanoclaw/baseimage_test.go deleted file mode 100644 index 5b23a7d..0000000 --- a/internal/driver/nanoclaw/baseimage_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package nanoclaw - -import ( - "strings" - "testing" - - "github.com/mostlydev/clawdapus/internal/driver" -) - -func TestBaseImageProvider(t *testing.T) { - d := &Driver{} - - var _ driver.BaseImageProvider = d - - tag, dockerfile := d.BaseImage() - - if tag != "nanoclaw-orchestrator:latest" { - t.Fatalf("expected tag nanoclaw-orchestrator:latest, got %q", tag) - } - if !strings.HasPrefix(dockerfile, "FROM node:22-bookworm-slim AS builder") { - t.Fatal("Dockerfile should use a builder stage") - } - if !strings.Contains(dockerfile, "https://github.com/qwibitai/nanoclaw.git") { - t.Fatal("Dockerfile should clone the nanoclaw upstream repository") - } - if !strings.Contains(dockerfile, "COPY --from=docker:27-cli /usr/local/bin/docker /usr/local/bin/docker") { - t.Fatal("Dockerfile should copy in the Docker CLI") - } - if !strings.Contains(dockerfile, "npm install -g @anthropic-ai/claude-code") { - t.Fatal("Dockerfile should install the Claude Code CLI") - } - if !strings.Contains(dockerfile, `ENTRYPOINT ["/usr/bin/tini", "--", "node", "/workspace/dist/index.js"]`) { - t.Fatal("Dockerfile should start the nanoclaw orchestrator") - } -} diff --git a/internal/driver/nanoclaw/driver.go b/internal/driver/nanoclaw/driver.go deleted file mode 100644 index 1de3485..0000000 --- a/internal/driver/nanoclaw/driver.go +++ /dev/null @@ -1,169 +0,0 @@ -package nanoclaw - -import ( - "context" - "fmt" - "os" - "path/filepath" - "time" - - "github.com/docker/docker/client" - "github.com/mostlydev/clawdapus/internal/cllama" - "github.com/mostlydev/clawdapus/internal/driver" - "github.com/mostlydev/clawdapus/internal/driver/shared" -) - -// Driver implements the Clawdapus driver interface for NanoClaw — -// a lightweight agent runtime using the Claude Agent SDK. -type Driver struct{} - -func init() { - driver.Register("nanoclaw", &Driver{}) -} - -func (d *Driver) Validate(rc *driver.ResolvedClaw) error { - if rc.AgentHostPath == "" { - return fmt.Errorf("nanoclaw driver: no agent host path specified (no contract, no start)") - } - if _, err := os.Stat(rc.AgentHostPath); err != nil { - return fmt.Errorf("nanoclaw driver: agent file %q not found: %w", rc.AgentHostPath, err) - } - if rc.Privileges == nil || rc.Privileges["docker-socket"] != "true" { - return fmt.Errorf("nanoclaw driver: requires PRIVILEGE docker-socket (nanoclaw spawns agent containers via Docker)") - } - if len(rc.Invocations) > 0 { - fmt.Printf("[claw] warning: nanoclaw driver: INVOKE scheduling not supported; ignoring %d invocations\n", len(rc.Invocations)) - } - return nil -} - -func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpts) (*driver.MaterializeResult, error) { - podName := opts.PodName - if podName == "" { - podName = rc.ServiceName - } - memoryDir, err := shared.PreparePortableMemory(shared.ResolveStateDir(opts.RuntimeDir, opts.StateDir), opts.RuntimeDir) - if err != nil { - return nil, fmt.Errorf("nanoclaw driver: prepare portable memory: %w", err) - } - - // Combine agent contract + CLAWDAPUS.md into single CLAUDE.md. - // Flows: orchestrator groups/main/ → agent-runner /workspace/group/CLAUDE.md → SDK auto-loads. - agentContent, err := os.ReadFile(rc.AgentHostPath) - if err != nil { - return nil, fmt.Errorf("nanoclaw driver: read agent contract: %w", err) - } - clawdapusMd := shared.GenerateClawdapusMD(rc, podName) - combined := string(agentContent) + "\n\n---\n\n" + clawdapusMd - combinedPath := filepath.Join(opts.RuntimeDir, "CLAUDE.md") - if err := os.WriteFile(combinedPath, []byte(combined), 0644); err != nil { - return nil, fmt.Errorf("nanoclaw driver: write combined CLAUDE.md: %w", err) - } - - mounts := []driver.Mount{ - {HostPath: combinedPath, ContainerPath: "/workspace/groups/main/CLAUDE.md", ReadOnly: true}, - {HostPath: "/var/run/docker.sock", ContainerPath: "/var/run/docker.sock", ReadOnly: false}, - {HostPath: memoryDir, ContainerPath: shared.PortableMemoryDir, ReadOnly: false}, - } - if rc.PersonaHostPath != "" { - mounts = append(mounts, driver.Mount{ - HostPath: rc.PersonaHostPath, - ContainerPath: "/workspace/container/persona", - ReadOnly: false, - }) - } - - env := map[string]string{ - "CLAW_MANAGED": "true", - shared.PortableMemoryEnv: shared.PortableMemoryDir, - } - if rc.PersonaHostPath != "" { - env["CLAW_PERSONA_DIR"] = "/workspace/container/persona" - } - - if len(rc.Cllama) > 0 { - firstProxy := cllama.ProxyBaseURL(rc.Cllama[0]) - env["ANTHROPIC_BASE_URL"] = firstProxy - // Compose network name: {project}_{network} - env["CLAW_NETWORK"] = fmt.Sprintf("%s_claw-internal", podName) - - if rc.CllamaToken != "" { - // .env file for orchestrator's readEnvFile() — passes to agent-runners via stdin - envContent := fmt.Sprintf("ANTHROPIC_API_KEY=%s\n", rc.CllamaToken) - envPath := filepath.Join(opts.RuntimeDir, ".env") - if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil { - return nil, fmt.Errorf("nanoclaw driver: write .env: %w", err) - } - mounts = append(mounts, driver.Mount{ - HostPath: envPath, - ContainerPath: "/workspace/.env", - ReadOnly: true, - }) - } - } - - return &driver.MaterializeResult{ - Mounts: mounts, - Tmpfs: []string{"/tmp"}, - ReadOnly: false, - Restart: "on-failure", - User: "0:0", - SkillDir: "/workspace/container/skills", - SkillLayout: "directory", - Healthcheck: &driver.Healthcheck{ - Test: []string{"CMD-SHELL", "pgrep -f 'node.*index' > /dev/null"}, - Interval: "30s", - Timeout: "10s", - Retries: 3, - }, - Environment: env, - }, nil -} - -func (d *Driver) PostApply(rc *driver.ResolvedClaw, opts driver.PostApplyOpts) error { - if opts.ContainerID == "" { - return fmt.Errorf("nanoclaw driver: post-apply check failed: no container ID") - } - cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) - if err != nil { - return fmt.Errorf("nanoclaw driver: post-apply docker client: %w", err) - } - defer cli.Close() - info, err := cli.ContainerInspect(context.Background(), opts.ContainerID) - if err != nil { - return fmt.Errorf("nanoclaw driver: post-apply inspect: %w", err) - } - if !info.State.Running { - cid := opts.ContainerID - if len(cid) > 12 { - cid = cid[:12] - } - return fmt.Errorf("nanoclaw driver: container %s not running (status: %s)", cid, info.State.Status) - } - return nil -} - -func (d *Driver) HealthProbe(ref driver.ContainerRef) (*driver.Health, error) { - if ref.ContainerID == "" { - return &driver.Health{OK: false, Detail: "no container ID"}, nil - } - cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) - if err != nil { - return nil, fmt.Errorf("nanoclaw driver: health docker client: %w", err) - } - defer cli.Close() - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - info, err := cli.ContainerInspect(ctx, ref.ContainerID) - if err != nil { - return &driver.Health{OK: false, Detail: fmt.Sprintf("inspect: %v", err)}, nil - } - if info.State == nil || !info.State.Running { - status := "unknown" - if info.State != nil { - status = info.State.Status - } - return &driver.Health{OK: false, Detail: fmt.Sprintf("not running (%s)", status)}, nil - } - return &driver.Health{OK: true, Detail: "container running"}, nil -} diff --git a/internal/driver/nanoclaw/driver_test.go b/internal/driver/nanoclaw/driver_test.go deleted file mode 100644 index 4f670c2..0000000 --- a/internal/driver/nanoclaw/driver_test.go +++ /dev/null @@ -1,349 +0,0 @@ -package nanoclaw - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/mostlydev/clawdapus/internal/driver" - "github.com/mostlydev/clawdapus/internal/driver/shared" -) - -func TestDriverRegistered(t *testing.T) { - d, err := driver.Lookup("nanoclaw") - if err != nil { - t.Fatalf("nanoclaw driver not registered: %v", err) - } - if d == nil { - t.Fatal("nanoclaw driver is nil") - } -} - -func TestValidateRequiresAgentPath(t *testing.T) { - d := &Driver{} - rc := &driver.ResolvedClaw{ - ServiceName: "test", - Privileges: map[string]string{"docker-socket": "true"}, - } - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for missing agent host path") - } - if !strings.Contains(err.Error(), "no agent host path") { - t.Errorf("unexpected error: %v", err) - } -} - -func TestValidateRequiresDockerSocket(t *testing.T) { - tmp := t.TempDir() - agentPath := filepath.Join(tmp, "AGENTS.md") - if err := os.WriteFile(agentPath, []byte("test"), 0644); err != nil { - t.Fatal(err) - } - - d := &Driver{} - rc := &driver.ResolvedClaw{ - ServiceName: "test", - AgentHostPath: agentPath, - Privileges: map[string]string{}, - } - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for missing docker-socket privilege") - } - if !strings.Contains(err.Error(), "PRIVILEGE docker-socket") { - t.Errorf("unexpected error: %v", err) - } -} - -func TestValidateAcceptsValidConfig(t *testing.T) { - tmp := t.TempDir() - agentPath := filepath.Join(tmp, "AGENTS.md") - if err := os.WriteFile(agentPath, []byte("test"), 0644); err != nil { - t.Fatal(err) - } - - d := &Driver{} - rc := &driver.ResolvedClaw{ - ServiceName: "test", - AgentHostPath: agentPath, - Privileges: map[string]string{"docker-socket": "true"}, - } - if err := d.Validate(rc); err != nil { - t.Fatalf("unexpected validation error: %v", err) - } -} - -func TestValidateWarnsOnInvocations(t *testing.T) { - tmp := t.TempDir() - agentPath := filepath.Join(tmp, "AGENTS.md") - if err := os.WriteFile(agentPath, []byte("test"), 0644); err != nil { - t.Fatal(err) - } - - d := &Driver{} - rc := &driver.ResolvedClaw{ - ServiceName: "test", - AgentHostPath: agentPath, - Privileges: map[string]string{"docker-socket": "true"}, - Invocations: []driver.Invocation{{Schedule: "0 * * * *", Message: "test"}}, - } - // Should not error, just warn - if err := d.Validate(rc); err != nil { - t.Fatalf("invocations should produce warning, not error: %v", err) - } -} - -// --- Materialize tests --- - -func newTestRC(t *testing.T) (*driver.ResolvedClaw, string) { - t.Helper() - tmp := t.TempDir() - agentPath := filepath.Join(tmp, "AGENTS.md") - if err := os.WriteFile(agentPath, []byte("# Test Agent\n\nYou are Allen."), 0644); err != nil { - t.Fatal(err) - } - rc := &driver.ResolvedClaw{ - ServiceName: "nano-bot", - ClawType: "nanoclaw", - AgentHostPath: agentPath, - Privileges: map[string]string{"docker-socket": "true"}, - } - return rc, tmp -} - -func TestMaterializeBasic(t *testing.T) { - rc, tmp := newTestRC(t) - runtimeDir := filepath.Join(tmp, "runtime") - if err := os.MkdirAll(runtimeDir, 0700); err != nil { - t.Fatal(err) - } - - d := &Driver{} - result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: runtimeDir, PodName: "test-pod"}) - if err != nil { - t.Fatalf("Materialize failed: %v", err) - } - - // ReadOnly must be false — NanoClaw needs writable workspace - if result.ReadOnly { - t.Error("expected ReadOnly=false for nanoclaw") - } - - // SkillDir targets orchestrator's container/skills path - if result.SkillDir != "/workspace/container/skills" { - t.Errorf("expected orchestrator skill dir /workspace/container/skills, got %q", result.SkillDir) - } - if result.SkillLayout != "directory" { - t.Errorf("expected directory skill layout, got %q", result.SkillLayout) - } - if result.User != "0:0" { - t.Errorf("expected User=0:0, got %q", result.User) - } - - // Check mounts - hasDockerSocket := false - hasCombinedClaude := false - hasOldAgent := false - hasOldClawdapus := false - for _, m := range result.Mounts { - if m.ContainerPath == "/var/run/docker.sock" { - hasDockerSocket = true - if m.ReadOnly { - t.Error("Docker socket mount should be read-write") - } - } - if m.ContainerPath == "/workspace/groups/main/CLAUDE.md" { - hasCombinedClaude = true - if !m.ReadOnly { - t.Error("combined CLAUDE.md mount should be read-only") - } - } - // Old paths must NOT appear - if m.ContainerPath == "/workspace/AGENTS.md" { - hasOldAgent = true - } - if m.ContainerPath == "/workspace/CLAWDAPUS.md" { - hasOldClawdapus = true - } - } - if !hasDockerSocket { - t.Error("expected Docker socket mount") - } - if !hasCombinedClaude { - t.Error("expected combined CLAUDE.md mount at /workspace/groups/main/CLAUDE.md") - } - if hasOldAgent { - t.Error("should NOT have old /workspace/AGENTS.md mount") - } - if hasOldClawdapus { - t.Error("should NOT have old /workspace/CLAWDAPUS.md mount") - } - - // Verify combined CLAUDE.md contains both agent content and CLAWDAPUS.md - combinedPath := filepath.Join(runtimeDir, "CLAUDE.md") - content, err := os.ReadFile(combinedPath) - if err != nil { - t.Fatalf("CLAUDE.md not written: %v", err) - } - if !strings.Contains(string(content), "Test Agent") { - t.Error("combined CLAUDE.md should contain agent contract content") - } - if !strings.Contains(string(content), "test-pod") { - t.Error("combined CLAUDE.md should contain pod name from CLAWDAPUS.md") - } - if !strings.Contains(string(content), "nano-bot") { - t.Error("combined CLAUDE.md should contain service name from CLAWDAPUS.md") - } - - // Environment - if result.Environment["CLAW_MANAGED"] != "true" { - t.Error("expected CLAW_MANAGED=true in environment") - } - if result.Environment[shared.PortableMemoryEnv] != shared.PortableMemoryDir { - t.Errorf("expected %s=%s, got %q", shared.PortableMemoryEnv, shared.PortableMemoryDir, result.Environment[shared.PortableMemoryEnv]) - } - - hasPortableMemory := false - for _, m := range result.Mounts { - if m.ContainerPath == shared.PortableMemoryDir { - hasPortableMemory = true - if m.ReadOnly { - t.Error("portable memory mount should be writable") - } - } - } - if !hasPortableMemory { - t.Error("expected portable memory mount") - } -} - -func TestMaterializeWithCllama(t *testing.T) { - rc, tmp := newTestRC(t) - rc.Cllama = []string{"passthrough"} - rc.CllamaToken = "nano-bot:abc123" - runtimeDir := filepath.Join(tmp, "runtime") - if err := os.MkdirAll(runtimeDir, 0700); err != nil { - t.Fatal(err) - } - - d := &Driver{} - result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: runtimeDir, PodName: "test-pod"}) - if err != nil { - t.Fatalf("Materialize failed: %v", err) - } - - // ANTHROPIC_BASE_URL as env var (orchestrator forwards to agent-runners) - if result.Environment["ANTHROPIC_BASE_URL"] != "http://cllama:8080/v1" { - t.Errorf("expected ANTHROPIC_BASE_URL rewritten to proxy, got %q", result.Environment["ANTHROPIC_BASE_URL"]) - } - - // ANTHROPIC_API_KEY must NOT be in env — goes to .env file instead - if _, ok := result.Environment["ANTHROPIC_API_KEY"]; ok { - t.Error("ANTHROPIC_API_KEY should NOT be in env vars — must go to .env file") - } - - // CLAW_NETWORK for agent-runner pod connectivity - if result.Environment["CLAW_NETWORK"] != "test-pod_claw-internal" { - t.Errorf("expected CLAW_NETWORK=test-pod_claw-internal, got %q", result.Environment["CLAW_NETWORK"]) - } - - // .env file mount with ANTHROPIC_API_KEY - hasEnvMount := false - for _, m := range result.Mounts { - if m.ContainerPath == "/workspace/.env" { - hasEnvMount = true - if !m.ReadOnly { - t.Error(".env mount should be read-only") - } - } - } - if !hasEnvMount { - t.Error("expected .env mount at /workspace/.env for cllama token") - } - - // Verify .env file content - envPath := filepath.Join(runtimeDir, ".env") - envContent, err := os.ReadFile(envPath) - if err != nil { - t.Fatalf(".env not written: %v", err) - } - envInfo, err := os.Stat(envPath) - if err != nil { - t.Fatalf(".env stat failed: %v", err) - } - if perm := envInfo.Mode().Perm(); perm != 0o600 { - t.Errorf("expected .env permissions 0600, got %o", perm) - } - if !strings.Contains(string(envContent), "ANTHROPIC_API_KEY=nano-bot:abc123") { - t.Errorf(".env should contain ANTHROPIC_API_KEY token, got %q", string(envContent)) - } -} - -func TestMaterializeMountsPersonaWorkspace(t *testing.T) { - rc, tmp := newTestRC(t) - personaDir := filepath.Join(tmp, "persona") - if err := os.MkdirAll(personaDir, 0o755); err != nil { - t.Fatal(err) - } - rc.Persona = "ghcr.io/mostlydev/personas/allen:latest" - rc.PersonaHostPath = personaDir - runtimeDir := filepath.Join(tmp, "runtime") - if err := os.MkdirAll(runtimeDir, 0o700); err != nil { - t.Fatal(err) - } - - d := &Driver{} - result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: runtimeDir, PodName: "test-pod"}) - if err != nil { - t.Fatalf("Materialize failed: %v", err) - } - - found := false - for _, mount := range result.Mounts { - if mount.ContainerPath == "/workspace/container/persona" { - found = true - if mount.ReadOnly { - t.Fatal("persona mount should be writable") - } - } - } - if !found { - t.Fatal("expected persona mount for nanoclaw") - } - if result.Environment["CLAW_PERSONA_DIR"] != "/workspace/container/persona" { - t.Fatalf("unexpected persona env: %q", result.Environment["CLAW_PERSONA_DIR"]) - } -} - -func TestMaterializeWithoutCllama(t *testing.T) { - rc, tmp := newTestRC(t) - runtimeDir := filepath.Join(tmp, "runtime") - if err := os.MkdirAll(runtimeDir, 0700); err != nil { - t.Fatal(err) - } - - d := &Driver{} - result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: runtimeDir, PodName: "test-pod"}) - if err != nil { - t.Fatalf("Materialize failed: %v", err) - } - - if _, ok := result.Environment["ANTHROPIC_BASE_URL"]; ok { - t.Error("expected no ANTHROPIC_BASE_URL when cllama not enabled") - } - if _, ok := result.Environment["ANTHROPIC_API_KEY"]; ok { - t.Error("expected no ANTHROPIC_API_KEY when cllama not enabled") - } - if _, ok := result.Environment["CLAW_NETWORK"]; ok { - t.Error("expected no CLAW_NETWORK when cllama not enabled") - } - - // No .env mount - for _, m := range result.Mounts { - if m.ContainerPath == "/workspace/.env" { - t.Error("expected no .env mount when cllama not enabled") - } - } -} diff --git a/internal/driver/nullclaw/baseimage.go b/internal/driver/nullclaw/baseimage.go deleted file mode 100644 index 09ae72a..0000000 --- a/internal/driver/nullclaw/baseimage.go +++ /dev/null @@ -1,16 +0,0 @@ -package nullclaw - -const baseImageTag = "nullclaw:latest" - -const baseImageDockerfile = `FROM ghcr.io/nullclaw/nullclaw:latest - -LABEL org.opencontainers.image.source="https://github.com/nullclaw/nullclaw" -` - -func (d *Driver) BaseImage() (string, string) { - return baseImageTag, baseImageDockerfile -} - -func (d *Driver) RunnerAlias() string { - return "nullclaw" -} diff --git a/internal/driver/nullclaw/baseimage_test.go b/internal/driver/nullclaw/baseimage_test.go deleted file mode 100644 index e62a99d..0000000 --- a/internal/driver/nullclaw/baseimage_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package nullclaw - -import ( - "strings" - "testing" - - "github.com/mostlydev/clawdapus/internal/driver" -) - -func TestBaseImageProvider(t *testing.T) { - d := &Driver{} - - var _ driver.BaseImageProvider = d - - tag, dockerfile := d.BaseImage() - - if tag != "nullclaw:latest" { - t.Fatalf("expected tag nullclaw:latest, got %q", tag) - } - if !strings.HasPrefix(dockerfile, "FROM ghcr.io/nullclaw/nullclaw:latest") { - t.Fatal("Dockerfile should alias the official nullclaw image") - } - if !strings.Contains(dockerfile, "https://github.com/nullclaw/nullclaw") { - t.Fatal("Dockerfile should point at the nullclaw upstream source") - } -} diff --git a/internal/driver/nullclaw/config.go b/internal/driver/nullclaw/config.go deleted file mode 100644 index f26c51b..0000000 --- a/internal/driver/nullclaw/config.go +++ /dev/null @@ -1,162 +0,0 @@ -package nullclaw - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/mostlydev/clawdapus/internal/driver" - "github.com/mostlydev/clawdapus/internal/driver/shared" -) - -// GenerateConfig builds a nullclaw JSON config from resolved Claw directives. -// Output is deterministic because map keys are sorted by encoding/json. -func GenerateConfig(rc *driver.ResolvedClaw) ([]byte, error) { - config := make(map[string]interface{}) - - // Conservative gateway defaults: keep local bind + pairing requirement. - if err := shared.SetPath(config, "gateway.port", 3000); err != nil { - return nil, fmt.Errorf("config generation: %w", err) - } - if err := shared.SetPath(config, "gateway.host", "127.0.0.1"); err != nil { - return nil, fmt.Errorf("config generation: %w", err) - } - if err := shared.SetPath(config, "gateway.require_pairing", true); err != nil { - return nil, fmt.Errorf("config generation: %w", err) - } - - // Safety defaults. - if err := shared.SetPath(config, "autonomy.level", "supervised"); err != nil { - return nil, fmt.Errorf("config generation: %w", err) - } - if err := shared.SetPath(config, "autonomy.workspace_only", true); err != nil { - return nil, fmt.Errorf("config generation: %w", err) - } - - for slot, model := range rc.Models { - if slot == "fallback" { - if err := shared.SetPath(config, "reliability.fallback_providers", []string{model}); err != nil { - return nil, fmt.Errorf("config generation: %w", err) - } - continue - } - if err := shared.SetPath(config, "agents.defaults.model."+slot, model); err != nil { - return nil, fmt.Errorf("config generation: %w", err) - } - } - - if len(rc.Cllama) > 0 { - if strings.TrimSpace(rc.CllamaToken) == "" { - return nil, fmt.Errorf("config generation: CLLAMA is enabled but token is empty") - } - firstProxy := fmt.Sprintf("http://cllama-%s:8080/v1", rc.Cllama[0]) - for _, provider := range shared.CollectProviders(rc.Models) { - base := "models.providers." + provider - if err := shared.SetPath(config, base+".base_url", firstProxy); err != nil { - return nil, fmt.Errorf("config generation: cllama provider %q base_url: %w", provider, err) - } - if err := shared.SetPath(config, base+".api_key", rc.CllamaToken); err != nil { - return nil, fmt.Errorf("config generation: cllama provider %q api_key: %w", provider, err) - } - } - } - - // Signal tool-based response delivery when channel handles are present. - // Agents post via explicit send_message rather than auto-routed text. - if len(rc.Handles) > 0 { - if err := shared.SetPath(config, "gateway.response_mode", "tool"); err != nil { - return nil, fmt.Errorf("config generation: gateway.response_mode: %w", err) - } - } - - // HANDLE defaults first. CONFIGURE runs last and overrides these values. - for platform, h := range rc.Handles { - switch strings.ToLower(platform) { - case "discord": - if err := shared.SetPath(config, "channels.discord.accounts.main.mention_only", true); err != nil { - return nil, fmt.Errorf("config generation: HANDLE discord: %w", err) - } - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "DISCORD_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: HANDLE discord: DISCORD_BOT_TOKEN: %w", err) - } - if token != "" { - if err := shared.SetPath(config, "channels.discord.accounts.main.token", token); err != nil { - return nil, fmt.Errorf("config generation: HANDLE discord: %w", err) - } - } - if h != nil { - for _, g := range h.Guilds { - gid := strings.TrimSpace(g.ID) - if gid == "" { - continue - } - if err := shared.SetPath(config, "channels.discord.accounts.main.guild_id", gid); err != nil { - return nil, fmt.Errorf("config generation: HANDLE discord: %w", err) - } - break - } - } - case "telegram": - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "TELEGRAM_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: HANDLE telegram: TELEGRAM_BOT_TOKEN: %w", err) - } - if token != "" { - if err := shared.SetPath(config, "channels.telegram.accounts.main.bot_token", token); err != nil { - return nil, fmt.Errorf("config generation: HANDLE telegram: %w", err) - } - } - case "slack": - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "SLACK_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: SLACK_BOT_TOKEN: %w", err) - } - if token != "" { - if err := shared.SetPath(config, "channels.slack.accounts.main.bot_token", token); err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: %w", err) - } - } - appToken, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "SLACK_APP_TOKEN", rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: SLACK_APP_TOKEN: %w", err) - } - if appToken != "" { - if err := shared.SetPath(config, "channels.slack.accounts.main.app_token", appToken); err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: %w", err) - } - if err := shared.SetPath(config, "channels.slack.accounts.main.mode", "socket"); err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: %w", err) - } - } - signingSecret, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "SLACK_SIGNING_SECRET", rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: SLACK_SIGNING_SECRET: %w", err) - } - if signingSecret != "" { - if err := shared.SetPath(config, "channels.slack.accounts.main.signing_secret", signingSecret); err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: %w", err) - } - if appToken == "" { - if err := shared.SetPath(config, "channels.slack.accounts.main.mode", "http"); err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: %w", err) - } - } - } - default: - fmt.Printf("[claw] warning: nullclaw driver has no config mapping for HANDLE platform %q; skipping channel enablement\n", platform) - } - } - - for _, cmd := range rc.Configures { - path, value, err := shared.ParseConfigSetCommand(cmd, "nullclaw") - if err != nil { - return nil, fmt.Errorf("config generation: %w", err) - } - if err := shared.SetPath(config, path, value); err != nil { - return nil, fmt.Errorf("config generation: %w", err) - } - } - - return json.MarshalIndent(config, "", " ") -} diff --git a/internal/driver/nullclaw/config_test.go b/internal/driver/nullclaw/config_test.go deleted file mode 100644 index 76266dd..0000000 --- a/internal/driver/nullclaw/config_test.go +++ /dev/null @@ -1,250 +0,0 @@ -package nullclaw - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/mostlydev/clawdapus/internal/driver" -) - -func getPath(data []byte, path string) (interface{}, bool) { - var m map[string]interface{} - if err := json.Unmarshal(data, &m); err != nil { - return nil, false - } - keys := strings.Split(path, ".") - var current interface{} = m - for _, key := range keys { - cm, ok := current.(map[string]interface{}) - if !ok { - return nil, false - } - current, ok = cm[key] - if !ok { - return nil, false - } - } - return current, true -} - -func TestGenerateConfigSetsGateway(t *testing.T) { - rc := &driver.ResolvedClaw{} - data, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - - if v, _ := getPath(data, "gateway.port"); v != float64(3000) { - t.Fatalf("expected gateway.port=3000, got %v", v) - } - if v, _ := getPath(data, "gateway.host"); v != "127.0.0.1" { - t.Fatalf("expected gateway.host=127.0.0.1, got %v", v) - } - if v, _ := getPath(data, "gateway.require_pairing"); v != true { - t.Fatalf("expected gateway.require_pairing=true, got %v", v) - } -} - -func TestGenerateConfigSetsModelPrimary(t *testing.T) { - rc := &driver.ResolvedClaw{ - Models: map[string]string{ - "primary": "openrouter/anthropic/claude-sonnet-4", - }, - } - data, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - if v, _ := getPath(data, "agents.defaults.model.primary"); v != "openrouter/anthropic/claude-sonnet-4" { - t.Fatalf("unexpected model.primary: %v", v) - } -} - -func TestGenerateConfigModelFallback(t *testing.T) { - rc := &driver.ResolvedClaw{ - Models: map[string]string{ - "primary": "anthropic/claude-sonnet-4", - "fallback": "openrouter/meta-llama/llama-3.3-70b-instruct", - }, - } - data, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - v, ok := getPath(data, "reliability.fallback_providers") - if !ok { - t.Fatal("expected reliability.fallback_providers") - } - arr, ok := v.([]interface{}) - if !ok || len(arr) != 1 || arr[0] != "openrouter/meta-llama/llama-3.3-70b-instruct" { - t.Fatalf("unexpected fallback providers: %#v", v) - } -} - -func TestGenerateConfigDiscordHandle(t *testing.T) { - rc := &driver.ResolvedClaw{ - Handles: map[string]*driver.HandleInfo{ - "discord": { - Guilds: []driver.GuildInfo{{ID: "123456"}}, - }, - }, - Environment: map[string]string{ - "DISCORD_BOT_TOKEN": "discord-token", - }, - } - data, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - if v, _ := getPath(data, "channels.discord.accounts.main.token"); v != "discord-token" { - t.Fatalf("unexpected discord token: %v", v) - } - if v, _ := getPath(data, "channels.discord.accounts.main.guild_id"); v != "123456" { - t.Fatalf("unexpected discord guild_id: %v", v) - } -} - -func TestGenerateConfigTelegramHandle(t *testing.T) { - rc := &driver.ResolvedClaw{ - Handles: map[string]*driver.HandleInfo{ - "telegram": {}, - }, - Environment: map[string]string{ - "TELEGRAM_BOT_TOKEN": "tg-token", - }, - } - data, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - if v, _ := getPath(data, "channels.telegram.accounts.main.bot_token"); v != "tg-token" { - t.Fatalf("unexpected telegram bot token: %v", v) - } -} - -func TestGenerateConfigSlackHandle(t *testing.T) { - rc := &driver.ResolvedClaw{ - Handles: map[string]*driver.HandleInfo{ - "slack": {}, - }, - Environment: map[string]string{ - "SLACK_BOT_TOKEN": "xoxb-123", - "SLACK_APP_TOKEN": "xapp-456", - "SLACK_SIGNING_SECRET": "secret-1", - }, - } - data, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - if v, _ := getPath(data, "channels.slack.accounts.main.bot_token"); v != "xoxb-123" { - t.Fatalf("unexpected slack bot token: %v", v) - } - if v, _ := getPath(data, "channels.slack.accounts.main.app_token"); v != "xapp-456" { - t.Fatalf("unexpected slack app token: %v", v) - } - if v, _ := getPath(data, "channels.slack.accounts.main.signing_secret"); v != "secret-1" { - t.Fatalf("unexpected slack signing secret: %v", v) - } - if v, _ := getPath(data, "channels.slack.accounts.main.mode"); v != "socket" { - t.Fatalf("unexpected slack mode: %v", v) - } -} - -func TestGenerateConfigCllamaRewrite(t *testing.T) { - rc := &driver.ResolvedClaw{ - Models: map[string]string{ - "primary": "openrouter/anthropic/claude-sonnet-4", - "fallback": "anthropic/claude-3-5-haiku", - }, - Cllama: []string{"passthrough"}, - CllamaToken: "agent-token", - } - data, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - - for _, provider := range []string{"openrouter", "anthropic"} { - basePath := "models.providers." + provider - if v, _ := getPath(data, basePath+".base_url"); v != "http://cllama-passthrough:8080/v1" { - t.Fatalf("unexpected %s.base_url: %v", basePath, v) - } - if v, _ := getPath(data, basePath+".api_key"); v != "agent-token" { - t.Fatalf("unexpected %s.api_key: %v", basePath, v) - } - } -} - -func TestGenerateConfigConfigure(t *testing.T) { - rc := &driver.ResolvedClaw{ - Configures: []string{ - "nullclaw config set gateway.port 8081", - "nullclaw config set gateway.host \"0.0.0.0\"", - }, - } - data, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - if v, _ := getPath(data, "gateway.port"); v != float64(8081) { - t.Fatalf("expected gateway.port override, got %v", v) - } - if v, _ := getPath(data, "gateway.host"); v != "0.0.0.0" { - t.Fatalf("expected gateway.host override, got %v", v) - } -} - -func TestGenerateConfigConfigureOverridesHandle(t *testing.T) { - rc := &driver.ResolvedClaw{ - Handles: map[string]*driver.HandleInfo{ - "discord": {}, - }, - Environment: map[string]string{ - "DISCORD_BOT_TOKEN": "default-token", - }, - Configures: []string{ - "nullclaw config set channels.discord.accounts.main.token \"override-token\"", - }, - } - data, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - if v, _ := getPath(data, "channels.discord.accounts.main.token"); v != "override-token" { - t.Fatalf("expected CONFIGURE override token, got %v", v) - } -} - -func TestGenerateConfigDeterministic(t *testing.T) { - rc := &driver.ResolvedClaw{ - Models: map[string]string{ - "primary": "openrouter/anthropic/claude-sonnet-4", - "fallback": "anthropic/claude-3-5-haiku", - }, - Handles: map[string]*driver.HandleInfo{ - "discord": {}, - "slack": {}, - }, - Environment: map[string]string{ - "DISCORD_BOT_TOKEN": "discord-token", - "SLACK_BOT_TOKEN": "xoxb-123", - }, - Configures: []string{ - "nullclaw config set gateway.port 3001", - }, - } - - first, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - second, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - if string(first) != string(second) { - t.Fatal("expected deterministic output") - } -} diff --git a/internal/driver/nullclaw/driver.go b/internal/driver/nullclaw/driver.go deleted file mode 100644 index ab552ba..0000000 --- a/internal/driver/nullclaw/driver.go +++ /dev/null @@ -1,339 +0,0 @@ -package nullclaw - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "github.com/docker/docker/client" - "github.com/mostlydev/clawdapus/internal/driver" - "github.com/mostlydev/clawdapus/internal/driver/shared" -) - -type Driver struct{} - -func init() { - driver.Register("nullclaw", &Driver{}) -} - -func (d *Driver) Validate(rc *driver.ResolvedClaw) error { - if rc.AgentHostPath == "" { - return fmt.Errorf("nullclaw driver: no agent host path specified (no contract, no start)") - } - if _, err := os.Stat(rc.AgentHostPath); err != nil { - return fmt.Errorf("nullclaw driver: agent file %q not found: %w", rc.AgentHostPath, err) - } - - for _, cmd := range rc.Configures { - if _, _, err := shared.ParseConfigSetCommand(cmd, "nullclaw"); err != nil { - return fmt.Errorf("nullclaw driver: unsupported CONFIGURE command %q: %w", cmd, err) - } - } - - for platform := range rc.Handles { - switch strings.ToLower(platform) { - case "discord": - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "DISCORD_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return fmt.Errorf("nullclaw driver: DISCORD_BOT_TOKEN: %w", err) - } - if token == "" { - return fmt.Errorf("nullclaw driver: HANDLE discord requires DISCORD_BOT_TOKEN in service environment") - } - case "telegram": - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "TELEGRAM_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return fmt.Errorf("nullclaw driver: TELEGRAM_BOT_TOKEN: %w", err) - } - if token == "" { - return fmt.Errorf("nullclaw driver: HANDLE telegram requires TELEGRAM_BOT_TOKEN in service environment") - } - case "slack": - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "SLACK_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return fmt.Errorf("nullclaw driver: SLACK_BOT_TOKEN: %w", err) - } - if token == "" { - return fmt.Errorf("nullclaw driver: HANDLE slack requires SLACK_BOT_TOKEN in service environment") - } - default: - fmt.Printf("[claw] warning: nullclaw driver has no HANDLE validation for platform %q; skipping\n", platform) - } - } - - return nil -} - -func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpts) (*driver.MaterializeResult, error) { - configData, err := GenerateConfig(rc) - if err != nil { - return nil, fmt.Errorf("nullclaw driver: config generation failed: %w", err) - } - memoryDir, err := shared.PreparePortableMemory(shared.ResolveStateDir(opts.RuntimeDir, opts.StateDir), opts.RuntimeDir) - if err != nil { - return nil, fmt.Errorf("nullclaw driver: prepare portable memory: %w", err) - } - - homeDir := filepath.Join(opts.RuntimeDir, "nullclaw-home") - if err := os.MkdirAll(homeDir, 0o777); err != nil { - return nil, fmt.Errorf("nullclaw driver: create nullclaw home dir: %w", err) - } - configPath := filepath.Join(homeDir, "config.json") - if err := os.WriteFile(configPath, configData, 0o644); err != nil { - return nil, fmt.Errorf("nullclaw driver: write config.json: %w", err) - } - - podName := opts.PodName - if podName == "" { - podName = rc.ServiceName - } - clawdapusPath := filepath.Join(opts.RuntimeDir, "CLAWDAPUS.md") - clawdapusMD := shared.GenerateClawdapusMD(rc, podName) - if err := os.WriteFile(clawdapusPath, []byte(clawdapusMD), 0o644); err != nil { - return nil, fmt.Errorf("nullclaw driver: write CLAWDAPUS.md: %w", err) - } - - mounts := []driver.Mount{ - { - HostPath: homeDir, - ContainerPath: "/root/.nullclaw", - ReadOnly: false, - }, - { - // Upstream image sets HOME=/nullclaw-data; mount both for compatibility. - HostPath: homeDir, - ContainerPath: "/nullclaw-data/.nullclaw", - ReadOnly: false, - }, - { - HostPath: rc.AgentHostPath, - ContainerPath: "/claw/AGENTS.md", - ReadOnly: true, - }, - { - HostPath: clawdapusPath, - ContainerPath: "/claw/CLAWDAPUS.md", - ReadOnly: true, - }, - { - HostPath: memoryDir, - ContainerPath: shared.PortableMemoryDir, - ReadOnly: false, - }, - } - if rc.PersonaHostPath != "" { - mounts = append(mounts, driver.Mount{ - HostPath: rc.PersonaHostPath, - ContainerPath: "/claw/persona", - ReadOnly: false, - }) - } - - env := map[string]string{ - "CLAW_MANAGED": "true", - shared.PortableMemoryEnv: shared.PortableMemoryDir, - "HOME": "/nullclaw-data", - } - if rc.PersonaHostPath != "" { - env["CLAW_PERSONA_DIR"] = "/claw/persona" - } - - return &driver.MaterializeResult{ - Mounts: mounts, - Tmpfs: []string{"/tmp"}, - ReadOnly: true, - Restart: "on-failure", - SkillDir: "/claw/skills", - SkillLayout: "", - Healthcheck: &driver.Healthcheck{ - Test: []string{"CMD-SHELL", "curl -fsS http://localhost:3000/health >/dev/null || exit 1"}, - Interval: "30s", - Timeout: "10s", - Retries: 3, - }, - Environment: env, - }, nil -} - -func (d *Driver) PostApply(rc *driver.ResolvedClaw, opts driver.PostApplyOpts) error { - if opts.ContainerID == "" { - return fmt.Errorf("nullclaw driver: post-apply check failed: no container ID") - } - - cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) - if err != nil { - return fmt.Errorf("nullclaw driver: post-apply failed to create docker client: %w", err) - } - defer cli.Close() - - inspectCtx, cancelInspect := context.WithTimeout(context.Background(), 15*time.Second) - defer cancelInspect() - info, err := cli.ContainerInspect(inspectCtx, opts.ContainerID) - if err != nil { - return fmt.Errorf("nullclaw driver: post-apply container inspect failed: %w", err) - } - if info.State == nil || !info.State.Running { - status := "unknown" - if info.State != nil && info.State.Status != "" { - status = info.State.Status - } - return fmt.Errorf("nullclaw driver: post-apply check failed: container is not running (status: %s)", status) - } - - nativeInvocations := make([]driver.Invocation, 0, len(rc.Invocations)) - for _, inv := range rc.Invocations { - if inv.Origin == driver.OriginPod { - continue - } - nativeInvocations = append(nativeInvocations, inv) - } - if len(nativeInvocations) == 0 { - return nil - } - - existing, err := listExistingCronJobs(cli, opts.ContainerID) - if err != nil { - return fmt.Errorf("nullclaw driver: post-apply failed to list cron jobs: %w", err) - } - - for _, inv := range nativeInvocations { - if strings.TrimSpace(inv.Name) != "" { - fmt.Printf("[claw] warning: nullclaw driver: INVOKE name %q is not supported by nullclaw cron CLI; ignoring\n", inv.Name) - } - if strings.TrimSpace(inv.To) != "" { - fmt.Printf("[claw] warning: nullclaw driver: INVOKE to=%q is not supported by nullclaw cron CLI; ignoring\n", inv.To) - } - - command, err := buildInvocationCommand(inv.Message) - if err != nil { - return fmt.Errorf("nullclaw driver: post-apply invalid INVOKE message: %w", err) - } - key := cronEntryKey(inv.Schedule, command) - if _, exists := existing[key]; exists { - fmt.Printf("[claw] nullclaw: cron already exists (schedule: %s)\n", inv.Schedule) - continue - } - - args := buildCronAddArgs(inv.Schedule, command) - execCtx, cancelExec := context.WithTimeout(context.Background(), 20*time.Second) - stdout, stderr, exitCode, execErr := shared.ExecInContainer(execCtx, cli, opts.ContainerID, args) - cancelExec() - if execErr != nil { - return fmt.Errorf("nullclaw driver: post-apply failed to add cron job (schedule: %s): %w", inv.Schedule, execErr) - } - if exitCode != 0 { - detail := strings.TrimSpace(stderr) - if detail == "" { - detail = strings.TrimSpace(stdout) - } - if detail == "" { - detail = "no output" - } - return fmt.Errorf("nullclaw driver: post-apply cron add failed (schedule: %s, exit: %d): %s", inv.Schedule, exitCode, detail) - } - - existing[key] = struct{}{} - fmt.Printf("[claw] nullclaw: registered cron job (schedule: %s)\n", inv.Schedule) - } - - return nil -} - -func (d *Driver) HealthProbe(ref driver.ContainerRef) (*driver.Health, error) { - if ref.ContainerID == "" { - return &driver.Health{OK: false, Detail: "no container ID"}, nil - } - - cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) - if err != nil { - return nil, fmt.Errorf("nullclaw driver: health probe failed to create docker client: %w", err) - } - defer cli.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - info, err := cli.ContainerInspect(ctx, ref.ContainerID) - if err != nil { - return &driver.Health{OK: false, Detail: fmt.Sprintf("container inspect failed: %v", err)}, nil - } - if info.State == nil || !info.State.Running { - status := "unknown" - if info.State != nil && info.State.Status != "" { - status = info.State.Status - } - return &driver.Health{OK: false, Detail: fmt.Sprintf("container is not running (status: %s)", status)}, nil - } - return &driver.Health{OK: true, Detail: "container running"}, nil -} - -func buildCronAddArgs(expression, command string) []string { - return []string{"nullclaw", "cron", "add", expression, command} -} - -func buildInvocationCommand(message string) (string, error) { - trimmed := strings.TrimSpace(message) - if trimmed == "" { - return "", fmt.Errorf("empty invocation message") - } - return "nullclaw agent -m " + shellQuote(trimmed), nil -} - -func shellQuote(s string) string { - if s == "" { - return "''" - } - return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'" -} - -func cronEntryKey(expression, command string) string { - return strings.TrimSpace(expression) + "\x1f" + strings.TrimSpace(command) -} - -func parseCronListOutput(text string) map[string]struct{} { - out := make(map[string]struct{}) - lines := strings.Split(text, "\n") - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if !strings.Contains(trimmed, " | ") || !strings.Contains(trimmed, "cmd:") { - continue - } - parts := strings.Split(trimmed, "|") - if len(parts) < 2 { - continue - } - expr := strings.TrimSpace(parts[1]) - cmdIdx := strings.LastIndex(trimmed, "cmd:") - if cmdIdx < 0 { - continue - } - cmd := strings.TrimSpace(trimmed[cmdIdx+len("cmd:"):]) - if expr == "" || cmd == "" { - continue - } - out[cronEntryKey(expr, cmd)] = struct{}{} - } - return out -} - -func listExistingCronJobs(cli *client.Client, containerID string) (map[string]struct{}, error) { - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - stdout, stderr, exitCode, err := shared.ExecInContainer(ctx, cli, containerID, []string{"nullclaw", "cron", "list"}) - if err != nil { - return nil, err - } - if exitCode != 0 { - detail := strings.TrimSpace(stderr) - if detail == "" { - detail = strings.TrimSpace(stdout) - } - if detail == "" { - detail = "no output" - } - return nil, fmt.Errorf("cron list failed (exit: %d): %s", exitCode, detail) - } - return parseCronListOutput(stdout + "\n" + stderr), nil -} diff --git a/internal/driver/nullclaw/driver_test.go b/internal/driver/nullclaw/driver_test.go deleted file mode 100644 index 9f8992b..0000000 --- a/internal/driver/nullclaw/driver_test.go +++ /dev/null @@ -1,332 +0,0 @@ -package nullclaw - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/mostlydev/clawdapus/internal/driver" - "github.com/mostlydev/clawdapus/internal/driver/shared" -) - -func TestDriverRegistered(t *testing.T) { - d, err := driver.Lookup("nullclaw") - if err != nil { - t.Fatalf("nullclaw driver not registered: %v", err) - } - if d == nil { - t.Fatal("nullclaw driver is nil") - } -} - -func TestValidateRequiresAgentPath(t *testing.T) { - d := &Driver{} - rc := &driver.ResolvedClaw{ServiceName: "null"} - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for missing agent host path") - } - if !strings.Contains(err.Error(), "no agent host path") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestValidateRequiresAgentFileExists(t *testing.T) { - d := &Driver{} - rc := &driver.ResolvedClaw{ - ServiceName: "null", - AgentHostPath: "/path/that/does/not/exist/AGENTS.md", - } - if err := d.Validate(rc); err == nil { - t.Fatal("expected error for missing agent file") - } -} - -func TestValidateAcceptsBasicConfig(t *testing.T) { - rc, _ := newTestRC(t) - d := &Driver{} - if err := d.Validate(rc); err != nil { - t.Fatalf("unexpected validation error: %v", err) - } -} - -func TestValidateDiscordHandleRequiresToken(t *testing.T) { - rc, _ := newTestRC(t) - rc.Handles = map[string]*driver.HandleInfo{ - "discord": {ID: "1"}, - } - rc.Environment = map[string]string{} - - d := &Driver{} - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for missing DISCORD_BOT_TOKEN") - } - if !strings.Contains(err.Error(), "DISCORD_BOT_TOKEN") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestValidateTelegramHandleRequiresToken(t *testing.T) { - rc, _ := newTestRC(t) - rc.Handles = map[string]*driver.HandleInfo{ - "telegram": {ID: "1"}, - } - rc.Environment = map[string]string{} - - d := &Driver{} - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for missing TELEGRAM_BOT_TOKEN") - } - if !strings.Contains(err.Error(), "TELEGRAM_BOT_TOKEN") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestValidateSlackHandleRequiresToken(t *testing.T) { - rc, _ := newTestRC(t) - rc.Handles = map[string]*driver.HandleInfo{ - "slack": {ID: "1"}, - } - rc.Environment = map[string]string{} - - d := &Driver{} - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for missing SLACK_BOT_TOKEN") - } - if !strings.Contains(err.Error(), "SLACK_BOT_TOKEN") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestValidateRejectsInvalidConfigureCommand(t *testing.T) { - rc, _ := newTestRC(t) - rc.Configures = []string{"some random command"} - - d := &Driver{} - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for invalid CONFIGURE command") - } - if !strings.Contains(err.Error(), "CONFIGURE") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestMaterializeWritesConfig(t *testing.T) { - rc, tmp := newTestRC(t) - - d := &Driver{} - _, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: tmp, PodName: "pod-a"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - configPath := filepath.Join(tmp, "nullclaw-home", "config.json") - if _, err := os.Stat(configPath); err != nil { - t.Fatalf("expected config at %s: %v", configPath, err) - } -} - -func TestMaterializeMounts(t *testing.T) { - rc, tmp := newTestRC(t) - - d := &Driver{} - result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: tmp, PodName: "pod-a"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var homeMount *driver.Mount - var imageHomeMount *driver.Mount - var agentMount *driver.Mount - var clawdapusMount *driver.Mount - var memoryMount *driver.Mount - for i := range result.Mounts { - m := &result.Mounts[i] - switch m.ContainerPath { - case "/root/.nullclaw": - homeMount = m - case "/nullclaw-data/.nullclaw": - imageHomeMount = m - case "/claw/AGENTS.md": - agentMount = m - case "/claw/CLAWDAPUS.md": - clawdapusMount = m - case shared.PortableMemoryDir: - memoryMount = m - } - } - - if homeMount == nil || homeMount.ReadOnly { - t.Fatal("expected writable /root/.nullclaw mount") - } - if imageHomeMount == nil || imageHomeMount.ReadOnly { - t.Fatal("expected writable /nullclaw-data/.nullclaw mount") - } - if agentMount == nil || !agentMount.ReadOnly { - t.Fatal("expected readonly /claw/AGENTS.md mount") - } - if clawdapusMount == nil || !clawdapusMount.ReadOnly { - t.Fatal("expected readonly /claw/CLAWDAPUS.md mount") - } - if memoryMount == nil || memoryMount.ReadOnly { - t.Fatal("expected writable portable memory mount") - } -} - -func TestMaterializeEnvironment(t *testing.T) { - rc, tmp := newTestRC(t) - - d := &Driver{} - result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: tmp}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result.Environment["CLAW_MANAGED"] != "true" { - t.Fatalf("expected CLAW_MANAGED=true, got %q", result.Environment["CLAW_MANAGED"]) - } - if result.Environment[shared.PortableMemoryEnv] != shared.PortableMemoryDir { - t.Fatalf("expected %s=%s, got %q", shared.PortableMemoryEnv, shared.PortableMemoryDir, result.Environment[shared.PortableMemoryEnv]) - } -} - -func TestMaterializeHealthcheck(t *testing.T) { - rc, tmp := newTestRC(t) - - d := &Driver{} - result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: tmp}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result.Healthcheck == nil || len(result.Healthcheck.Test) == 0 { - t.Fatal("expected healthcheck config") - } - joined := strings.Join(result.Healthcheck.Test, " ") - if !strings.Contains(joined, "/health") { - t.Fatalf("expected /health in healthcheck command, got %q", joined) - } -} - -func TestMaterializeSkillDir(t *testing.T) { - rc, tmp := newTestRC(t) - - d := &Driver{} - result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: tmp}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result.SkillDir != "/claw/skills" { - t.Fatalf("unexpected skill dir: %q", result.SkillDir) - } - if result.SkillLayout != "" { - t.Fatalf("unexpected skill layout: %q", result.SkillLayout) - } -} - -func TestMaterializeCllamaConfig(t *testing.T) { - rc, tmp := newTestRC(t) - rc.Models = map[string]string{ - "primary": "anthropic/claude-sonnet-4", - } - rc.Cllama = []string{"passthrough"} - rc.CllamaToken = "token-a" - - d := &Driver{} - if _, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: tmp}); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - configPath := filepath.Join(tmp, "nullclaw-home", "config.json") - data, err := os.ReadFile(configPath) - if err != nil { - t.Fatalf("read config: %v", err) - } - if !strings.Contains(string(data), "http://cllama-passthrough:8080/v1") { - t.Fatalf("expected cllama base_url in config: %s", string(data)) - } - if !strings.Contains(string(data), "\"api_key\": \"token-a\"") { - t.Fatalf("expected cllama api_key in config: %s", string(data)) - } -} - -func TestPostApplyRequiresContainerID(t *testing.T) { - rc, _ := newTestRC(t) - d := &Driver{} - err := d.PostApply(rc, driver.PostApplyOpts{}) - if err == nil { - t.Fatal("expected error for missing container ID") - } -} - -func TestBuildInvocationCommand(t *testing.T) { - cmd, err := buildInvocationCommand("hello 'world'") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - expected := "nullclaw agent -m 'hello '\"'\"'world'\"'\"''" - if cmd != expected { - t.Fatalf("unexpected command:\nwant: %s\ngot: %s", expected, cmd) - } -} - -func TestBuildInvocationCommandEmptyMessage(t *testing.T) { - _, err := buildInvocationCommand(" ") - if err == nil { - t.Fatal("expected error for empty message") - } -} - -func TestBuildCronAddArgs(t *testing.T) { - args := buildCronAddArgs("*/5 * * * *", "nullclaw agent -m 'hello'") - want := []string{"nullclaw", "cron", "add", "*/5 * * * *", "nullclaw agent -m 'hello'"} - if len(args) != len(want) { - t.Fatalf("unexpected args len: %#v", args) - } - for i := range want { - if args[i] != want[i] { - t.Fatalf("unexpected arg[%d]: want %q got %q", i, want[i], args[i]) - } - } -} - -func TestParseCronListOutput(t *testing.T) { - text := ` -info(cron): Scheduled jobs (2): -info(cron): - job-1 | */5 * * * * | next=1740700000 | status=n/a cmd: nullclaw agent -m 'hello' -info(cron): - job-2 | 0 9 * * 1-5 | next=1740701000 | status=n/a cmd: nullclaw agent -m 'market open' -` - parsed := parseCronListOutput(text) - if len(parsed) != 2 { - t.Fatalf("expected 2 parsed cron jobs, got %d", len(parsed)) - } - if _, ok := parsed[cronEntryKey("*/5 * * * *", "nullclaw agent -m 'hello'")]; !ok { - t.Fatalf("missing expected key for first cron job: %#v", parsed) - } - if _, ok := parsed[cronEntryKey("0 9 * * 1-5", "nullclaw agent -m 'market open'")]; !ok { - t.Fatalf("missing expected key for second cron job: %#v", parsed) - } -} - -func newTestRC(t *testing.T) (*driver.ResolvedClaw, string) { - t.Helper() - tmp := t.TempDir() - agentPath := filepath.Join(tmp, "AGENTS.md") - if err := os.WriteFile(agentPath, []byte("# Agent\n\nYou are nullclaw."), 0o644); err != nil { - t.Fatal(err) - } - - rc := &driver.ResolvedClaw{ - ServiceName: "null", - ClawType: "nullclaw", - AgentHostPath: agentPath, - Models: map[string]string{ - "primary": "anthropic/claude-sonnet-4", - }, - Environment: map[string]string{}, - } - return rc, tmp -} diff --git a/internal/driver/registry.go b/internal/driver/registry.go index 39b8225..bb7b9c4 100644 --- a/internal/driver/registry.go +++ b/internal/driver/registry.go @@ -16,11 +16,24 @@ func Register(name string, d Driver) { drivers[name] = d } +// retiredRunners maps a runner dropped by ADR-026 to the reason it went and +// the supported runner closest to it. A retired type fails compilation with +// this guidance rather than the generic unknown-driver message, so an operator +// on an old pod file learns what happened instead of guessing at a typo. +var retiredRunners = map[string]string{ + "nanoclaw": "no independent distribution channel to corroborate adoption, and the steepest adoption decay in the runner set; closest supported runner: nanobot", + "microclaw": "no material adoption growth; closest supported runner: picoclaw", + "nullclaw": "upstream is no longer viable (no release in over 60 days); closest supported runner: picoclaw", +} + func Lookup(name string) (Driver, error) { mu.RLock() defer mu.RUnlock() d, ok := drivers[name] if !ok { + if reason, retired := retiredRunners[name]; retired { + return nil, fmt.Errorf("CLAW_TYPE %q was retired in ADR-026: %s", name, reason) + } return nil, fmt.Errorf("unknown CLAW_TYPE %q: no registered driver", name) } return d, nil diff --git a/internal/driver/retired_test.go b/internal/driver/retired_test.go new file mode 100644 index 0000000..289f284 --- /dev/null +++ b/internal/driver/retired_test.go @@ -0,0 +1,55 @@ +package driver + +import ( + "strings" + "testing" +) + +// Retired runners must fail closed with guidance, not fall through to the +// generic "no registered driver" message. See ADR-026. +func TestLookupRejectsRetiredRunnersWithMigrationGuidance(t *testing.T) { + for _, name := range []string{"nanoclaw", "microclaw", "nullclaw"} { + t.Run(name, func(t *testing.T) { + d, err := Lookup(name) + if err == nil { + t.Fatalf("expected %s to be rejected, got driver %T", name, d) + } + msg := err.Error() + if !strings.Contains(msg, name) { + t.Errorf("error should name the retired runner, got %q", msg) + } + if !strings.Contains(msg, "retired") { + t.Errorf("error should say the runner was retired, got %q", msg) + } + if !strings.Contains(msg, "ADR-026") { + t.Errorf("error should point at the decision record, got %q", msg) + } + for _, retained := range []string{"openclaw", "hermes", "picoclaw", "nanobot"} { + if strings.Contains(msg, retained) { + return + } + } + t.Errorf("error should name at least one supported runner to migrate to, got %q", msg) + }) + } +} + +func TestRetiredRunnersAreNotRegistered(t *testing.T) { + for _, name := range []string{"nanoclaw", "microclaw", "nullclaw"} { + if _, ok := Registered()[name]; ok { + t.Errorf("retired runner %s is still registered", name) + } + } +} + +// An unrelated unknown type keeps the generic message; only the runners we +// deliberately dropped get migration guidance. +func TestLookupKeepsGenericErrorForUnknownRunner(t *testing.T) { + _, err := Lookup("not-a-runner") + if err == nil { + t.Fatal("expected unknown runner to be rejected") + } + if strings.Contains(err.Error(), "retired") { + t.Errorf("unknown runner should not be reported as retired, got %q", err) + } +} diff --git a/internal/driver/shared/history.go b/internal/driver/shared/history.go index bd95941..2dab21b 100644 --- a/internal/driver/shared/history.go +++ b/internal/driver/shared/history.go @@ -53,12 +53,6 @@ func legacyPortableHistoryDirs(root string) []portableHistoryDirMapping { {srcPath: filepath.Join(root, "nanobot-home", "sessions"), dstRel: filepath.Join("nanobot", "sessions")}, {srcPath: filepath.Join(root, "picoclaw-home", "workspace", "sessions"), dstRel: filepath.Join("picoclaw", "sessions")}, {srcPath: filepath.Join(root, "picoclaw-home", "sessions"), dstRel: filepath.Join("picoclaw", "sessions")}, - {srcPath: filepath.Join(root, "data", "working_dir", "sessions"), dstRel: filepath.Join("microclaw", "sessions")}, - {srcPath: filepath.Join(root, "data", "working_dir", "history"), dstRel: filepath.Join("microclaw", "history")}, - {srcPath: filepath.Join(root, "nullclaw-home", "sessions"), dstRel: filepath.Join("nullclaw", "sessions")}, - {srcPath: filepath.Join(root, "nullclaw-home", "history"), dstRel: filepath.Join("nullclaw", "history")}, - {srcPath: filepath.Join(root, "data", "sessions"), dstRel: filepath.Join("nanoclaw", "data-sessions")}, - {srcPath: filepath.Join(root, "store", "sessions"), dstRel: filepath.Join("nanoclaw", "store-sessions")}, } } diff --git a/internal/pod/parser_handles_test.go b/internal/pod/parser_handles_test.go index 4023f62..ab6a546 100644 --- a/internal/pod/parser_handles_test.go +++ b/internal/pod/parser_handles_test.go @@ -186,10 +186,10 @@ services: handles: discord: "999000111" - nc-roll: - image: nullclaw:latest + nb-roll: + image: nanobot:latest x-claw: - agent: ./NC.md + agent: ./NB.md handles: discord: "999000111" ` diff --git a/site/changelog.md b/site/changelog.md index 4573ffd..b5b3e6e 100644 --- a/site/changelog.md +++ b/site/changelog.md @@ -29,6 +29,7 @@ outline: deep ## Unreleased +- **NanoClaw, MicroClaw, and NullClaw drivers retired** ([ADR-026](https://github.com/mostlydev/clawdapus/blob/master/docs/decisions/026-runner-adoption-and-retirement.md), [#353](https://github.com/mostlydev/clawdapus/issues/353)) -- a reproducible upstream-adoption audit (`scripts/runner-adoption-snapshot`, dated evidence under `docs/evidence/`) retired the three runners whose upstream adoption, viability, or corroborating distribution could not justify their maintenance cost. Retired `CLAW_TYPE`s now fail `claw up` with a migration error naming the closest supported runner instead of a generic unknown-driver message. The retained set is `openclaw`, `hermes`, `nanobot`, and `picoclaw`; the rollcall conformance pod and trading-desk example were revised to keep full coverage across all four. - **Slow scheduled wakes no longer block unrelated targets** -- claw-api dispatches due targets concurrently while serializing wakes per runner, coalesces overlapping slots without regressing next-fire state, rejects duplicate manual fires with a conflict, and drains active scheduler dispatches cleanly on shutdown. Coalesced slots are now recorded in schedule state (`suppressed_slots`, `last_suppressed_at`) and surfaced on the clawdash schedule card, so a schedule whose wake outruns its own cadence no longer reads as perfectly healthy. Closes [#347](https://github.com/mostlydev/clawdapus/issues/347). - **Manual schedule fires honor runner wake budgets** -- `claw api schedule fire` now gives the in-container request 2 minutes 5 seconds and its outer compose transport 2 minutes 10 seconds, enough to return the final result of the longest supported runner wake. Other schedule operations retain their short defaults, and an explicit `--exec-timeout` still overrides the outer transport. Closes [#348](https://github.com/mostlydev/clawdapus/issues/348). - **Hermes scheduled wakes get a runner-sized timeout** -- claw-api now gives `hermes-exec` the same two-minute wake budget as other runner-native cron paths, preventing a successfully triggered Hermes job from being recorded as `exec timed out` solely because the runner took longer than the generic 30-second transport budget. Closes [#345](https://github.com/mostlydev/clawdapus/issues/345). diff --git a/site/guide/anatomy.md b/site/guide/anatomy.md index 03c3b7e..aeeda04 100644 --- a/site/guide/anatomy.md +++ b/site/guide/anatomy.md @@ -26,11 +26,8 @@ Supported runners today: |--------|---------| | `openclaw` | [OpenClaw](https://openclaw.ai) | | `hermes` | [Hermes](https://github.com/NousResearch/hermes-agent) | -| `nanoclaw` | NanoClaw / Claude Code-compatible orchestrator | | `nanobot` | [Nanobot](https://github.com/HKUDS/nanobot) | | `picoclaw` | [PicoClaw](https://github.com/sipeed/picoclaw) | -| `nullclaw` | [NullClaw](https://github.com/nullclaw/nullclaw) | -| `microclaw` | [MicroClaw](https://github.com/microclaw/microclaw) | Pick a runner based on what you need. Swap it without touching the persona or the contract. A 400-line Python script with brokerage API access needs the exact same purpose contract and governance proxy as a massive agent OS. diff --git a/site/guide/clawfile.md b/site/guide/clawfile.md index 8a2fe4d..f7fca0e 100644 --- a/site/guide/clawfile.md +++ b/site/guide/clawfile.md @@ -43,7 +43,7 @@ This declares an OpenClaw agent that: | Directive | Purpose | |-----------|---------| -| `CLAW_TYPE` | Selects the runtime driver (`openclaw`, `hermes`, `nanobot`, `picoclaw`, `nanoclaw`, `microclaw`, `nullclaw`) | +| `CLAW_TYPE` | Selects the runtime driver (`openclaw`, `hermes`, `nanobot`, `picoclaw`) | | `AGENT` | Names the behavioral contract file to be bind-mounted read-only | | `PERSONA` | Imports a persona workspace -- local path or OCI artifact ref | | `MODEL` | Binds named model slots (e.g., `primary`, `fallback`) to providers | @@ -160,14 +160,14 @@ For example, `CLAW_TYPE openclaw` becomes a label on the image. `MODEL primary o The `CLAW_TYPE` directive selects which runtime driver handles the agent. All drivers support `MODEL`, `AGENT`, `CLLAMA`, and `CONFIGURE`. Platform support varies: -| Capability | `openclaw` | `hermes` | `nanoclaw` | `nanobot` | `picoclaw` | `nullclaw` | `microclaw` | -|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:| -| HANDLE: Discord | yes | yes | -- | yes | yes | yes | yes | -| HANDLE: Telegram | yes | yes | -- | yes | yes | yes | yes | -| HANDLE: Slack | yes | yes | -- | yes | yes | yes | yes | -| INVOKE (cron) | yes | yes | -- | yes | yes | yes | -- | -| Structured health | yes | yes | yes | yes | yes | yes | yes | -| Read-only rootfs | yes | yes | no | yes | yes | yes | no | +| Capability | `openclaw` | `hermes` | `nanobot` | `picoclaw` | +|---|:---:|:---:|:---:|:---:| +| HANDLE: Discord | yes | yes | yes | yes | +| HANDLE: Telegram | yes | yes | yes | yes | +| HANDLE: Slack | yes | yes | yes | yes | +| INVOKE (cron) | yes | yes | yes | yes | +| Structured health | yes | yes | yes | yes | +| Read-only rootfs | yes | yes | yes | yes | ## MODEL Slots @@ -192,11 +192,11 @@ Clawfile `MODEL` labels are the base slot map, but `claw-pod.yml` can retarget s ```dockerfile HANDLE discord -# Pin to one guild -CONFIGURE nullclaw config set channels.discord.accounts.main.guild_id "123456789012345678" +# Enable a channel beyond HANDLE defaults +CONFIGURE picoclaw config set channels.discord.enabled true -# Require mention in group chats -CONFIGURE nullclaw config set channels.discord.accounts.main.require_mention true +# Override the gateway port +CONFIGURE picoclaw config set gateway.port 19000 ``` ::: tip Defaults First, Then Override @@ -234,11 +234,9 @@ PRIVILEGE worker root PRIVILEGE runtime claw-user ``` -The syntax is `PRIVILEGE `. The compiler emits labels such as `claw.privilege.runtime=claw-user`; enforcement depends on the selected driver. NanoClaw currently requires an explicit Docker-socket grant because it spawns nested agent containers: +The syntax is `PRIVILEGE `. The compiler emits labels such as `claw.privilege.runtime=claw-user`; enforcement depends on the selected driver. A driver that spawns nested agent containers would require an explicit Docker-socket grant: ```dockerfile -CLAW_TYPE nanoclaw -AGENT AGENTS.md PRIVILEGE docker-socket true ``` diff --git a/site/guide/cli.md b/site/guide/cli.md index 759d28a..d79a7c1 100644 --- a/site/guide/cli.md +++ b/site/guide/cli.md @@ -327,7 +327,7 @@ When run interactively (no flags), prompts for project name, agent name, claw ty | `--source ` | Source runtime override when `--from` autodetection is ambiguous. | | `--project ` | Project name (used for `x-claw.pod` and image prefix). | | `--agent ` | Primary agent name. | -| `--type ` | Claw type (openclaw, hermes, nanoclaw, nanobot, picoclaw, nullclaw, microclaw, generic). | +| `--type ` | Claw type (openclaw, hermes, nanobot, picoclaw, generic). | | `--model ` | Primary model. | | `--cllama ` | Enable cllama proxy. | | `--platform ` | Platform handle (discord, slack, telegram, none). | diff --git a/site/guide/drivers.md b/site/guide/drivers.md index 7830622..12fb18c 100644 --- a/site/guide/drivers.md +++ b/site/guide/drivers.md @@ -6,18 +6,25 @@ New to Hermes? Start with the dedicated [Hermes quickstart](/guide/hermes). ## Feature Matrix -| | `openclaw` | `hermes` | `nanoclaw` | `nanobot` | `picoclaw` | `nullclaw` | `microclaw` | -|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:| -| **Runtime** | [OpenClaw](https://openclaw.ai) | [Hermes](https://github.com/NousResearch/hermes-agent) | NanoClaw / Claude Code-compatible orchestrator | [Nanobot](https://github.com/HKUDS/nanobot) | [PicoClaw](https://github.com/sipeed/picoclaw) | [NullClaw](https://github.com/nullclaw/nullclaw) | [MicroClaw](https://github.com/microclaw/microclaw) | -| `claw init` scaffold | yes | yes | yes | yes | yes | yes | yes | -| HANDLE: Discord | yes | yes | -- | yes | yes | yes | yes | -| HANDLE: Telegram | yes | yes | -- | yes | yes | yes | yes | -| HANDLE: Slack | yes | yes | -- | yes | yes | yes | yes | -| HANDLE: long-tail | -- | -- | -- | -- | yes | -- | -- | -| INVOKE (cron) | yes | yes | -- | yes | yes | yes | -- | -| Structured health | yes | yes | yes | yes | yes | yes | yes | -| Read-only rootfs | yes | yes | no | yes | yes | yes | no | -| Non-root container | -- | -- | -- | -- | yes | -- | -- | +| | `openclaw` | `hermes` | `nanobot` | `picoclaw` | +|---|:---:|:---:|:---:|:---:| +| **Runtime** | [OpenClaw](https://openclaw.ai) | [Hermes](https://github.com/NousResearch/hermes-agent) | [Nanobot](https://github.com/HKUDS/nanobot) | [PicoClaw](https://github.com/sipeed/picoclaw) | +| `claw init` scaffold | yes | yes | yes | yes | +| HANDLE: Discord | yes | yes | yes | yes | +| HANDLE: Telegram | yes | yes | yes | yes | +| HANDLE: Slack | yes | yes | yes | yes | +| HANDLE: long-tail | -- | -- | -- | yes | +| INVOKE (cron) | yes | yes | yes | yes | +| Structured health | yes | yes | yes | yes | +| Read-only rootfs | yes | yes | yes | yes | +| Non-root container | -- | -- | -- | yes | + +::: info Retired drivers +`nanoclaw`, `microclaw`, and `nullclaw` were retired in +[ADR-026](https://github.com/mostlydev/clawdapus/blob/master/docs/decisions/026-runner-adoption-and-retirement.md). +Their `CLAW_TYPE`s fail `claw up` with a migration message naming the closest +supported runner. +::: **PicoClaw long-tail platforms:** WhatsApp, Feishu, LINE, QQ, DingTalk, OneBot, WeCom, WeCom App, Pico, MaixCam. @@ -93,10 +100,6 @@ as channel prose; set `HERMES_CHAT_STATUS_DELIVERY=on` or configure that runtime telemetry. Unset `HERMES_CHAT_STATUS_DELIVERY` preserves upstream Hermes behavior; the quiet default is applied by the Clawdapus driver. -### nanoclaw - -NanoClaw / Claude Code-compatible orchestrator driver. It does not currently support HANDLE or INVOKE, and it requires `PRIVILEGE docker-socket true` because the runtime spawns agent containers through Docker. It has a structured health probe and uses a writable root filesystem. - ### nanobot Nanobot driver with generated config and Discord/Telegram/Slack handle wiring. Supports INVOKE scheduling and read-only rootfs. @@ -105,19 +108,11 @@ Nanobot driver with generated config and Discord/Telegram/Slack handle wiring. S PicoClaw is the most platform-diverse driver, supporting the long-tail of chat platforms beyond Discord/Telegram/Slack. Supports model-list config, non-root containers, and structured health probes. -### nullclaw - -NullClaw supports CONFIGURE for fine-grained runtime config mutations. Use `CONFIGURE nullclaw config set ` to pin guild IDs, set mention requirements, configure Telegram allowlists, or select Slack transport modes. - -### microclaw - -Minimal driver supporting Discord, Telegram, and Slack handles. Does not support INVOKE scheduling. It has a structured health probe and uses a writable root filesystem. - ## Choosing a Driver - **Need Discord routing controls?** `openclaw` has the richest Discord config support. -- **Need Telegram or Slack?** `openclaw`, `hermes`, `nanobot`, `picoclaw`, `nullclaw`, or `microclaw`. +- **Need Telegram or Slack?** `openclaw`, `hermes`, `nanobot`, or `picoclaw`. - **Need WhatsApp, LINE, or other platforms?** `picoclaw` is the only option. - **Need non-root containers?** `picoclaw`. -- **Need fine-grained runtime config?** `nullclaw` with `CONFIGURE`. +- **Need fine-grained runtime config?** every driver supports `CONFIGURE config set `. - **Just need a governed container?** `generic` type gives you an alpine base with no driver enforcement. diff --git a/site/guide/social-topology.md b/site/guide/social-topology.md index f166368..58951d6 100644 --- a/site/guide/social-topology.md +++ b/site/guide/social-topology.md @@ -122,12 +122,12 @@ services: An agent can have handles on multiple platforms simultaneously. Platform support varies by driver: -| Platform | `openclaw` | `hermes` | `nanoclaw` | `nanobot` | `picoclaw` | `nullclaw` | `microclaw` | -|----------|:---:|:---:|:---:|:---:|:---:|:---:|:---:| -| Discord | yes | yes | -- | yes | yes | yes | yes | -| Telegram | yes | yes | -- | yes | yes | yes | yes | -| Slack | yes | yes | -- | yes | yes | yes | yes | -| Long-tail chat | -- | -- | -- | -- | yes | -- | -- | +| Platform | `openclaw` | `hermes` | `nanobot` | `picoclaw` | +|----------|:---:|:---:|:---:|:---:| +| Discord | yes | yes | yes | yes | +| Telegram | yes | yes | yes | yes | +| Slack | yes | yes | yes | yes | +| Long-tail chat | -- | -- | -- | yes | PicoClaw additionally supports WhatsApp, Feishu, LINE, QQ, DingTalk, OneBot, WeCom, and other long-tail platforms. diff --git a/skills/clawdapus/SKILL.md b/skills/clawdapus/SKILL.md index 4ededea..507b8c4 100644 --- a/skills/clawdapus/SKILL.md +++ b/skills/clawdapus/SKILL.md @@ -445,10 +445,9 @@ When the aggregate cap drops a feed the model sees an explicit `--- FEED: | OpenClaw | `openclaw` | OpenClaw | JSON5 Go-native patching -> `openclaw.json` | Primary driver. Read-only container. Docker exec health probe. | | Hermes | `hermes` | Hermes (Python) | `config.yaml` + `.env` | Discord/Telegram/Slack. `HERMES_TOOL_ONLY_MODE`. Requires at least one handle. | | NanoBot | `nanobot` | Nanobot (Node.js) | `config.json` | Cron via `jobs.json`. Merged AGENTS.md. | -| NanoClaw | `nanoclaw` | Claude Agent SDK | Combined `CLAUDE.md` | Requires `PRIVILEGE docker-socket true`. Mounts Docker socket. | | PicoClaw | `picoclaw` | PicoClaw | `config.json` | HTTP `/health` + `/ready` probe. Read-only container. | -| MicroClaw | `microclaw` | MicroClaw (YAML) | `microclaw.config.yaml` | Built-in web UI on port 10961. No INVOKE support. | -| NullClaw | `nullclaw` | NullClaw (HTTP) | `config.json` | Cron via `PostApply` exec (not pre-written). Read-only container. | + +Retired (ADR-026): `nanoclaw`, `microclaw`, `nullclaw` — these `CLAW_TYPE`s fail `claw up` with a migration message. All drivers set `CLAW_MANAGED=true`, explicit `HOME`, and `DISCORD_REQUIRE_MENTION` (or equivalent) to prevent feedback loops. diff --git a/testdata/nanobot-stub/AGENTS.md b/testdata/nanobot-stub/AGENTS.md new file mode 100644 index 0000000..9891272 --- /dev/null +++ b/testdata/nanobot-stub/AGENTS.md @@ -0,0 +1,3 @@ +# MicroClaw Stub Contract + +You are a microclaw stub agent used for clawdapus integration testing. diff --git a/testdata/nanobot-stub/Clawfile b/testdata/nanobot-stub/Clawfile new file mode 100644 index 0000000..04e93f3 --- /dev/null +++ b/testdata/nanobot-stub/Clawfile @@ -0,0 +1,13 @@ +FROM alpine:3.20 + +CLAW_TYPE nanobot +AGENT AGENTS.md + +MODEL primary anthropic/claude-sonnet-4 + +RUN apk add --no-cache bash tini procps +COPY nanobot /usr/local/bin/nanobot +RUN chmod +x /usr/local/bin/nanobot +COPY entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh +ENTRYPOINT ["/sbin/tini", "--", "/app/entrypoint.sh"] diff --git a/testdata/nanobot-stub/entrypoint.sh b/testdata/nanobot-stub/entrypoint.sh new file mode 100755 index 0000000..8179b9d --- /dev/null +++ b/testdata/nanobot-stub/entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -e + +exec nanobot gateway diff --git a/testdata/nanobot-stub/nanobot b/testdata/nanobot-stub/nanobot new file mode 100755 index 0000000..982d72d --- /dev/null +++ b/testdata/nanobot-stub/nanobot @@ -0,0 +1,8 @@ +#!/bin/sh +# Stub nanobot process for integration/spike tests. +# Keep this script alive so `pgrep -f 'nanobot gateway'` healthcheck passes. + +echo "nanobot-stub: running $* (pid $$)" >&2 +while true; do + sleep 3600 +done From 55bcd4cff21393bcb5df00605ecb5ec2d73c64d7 Mon Sep 17 00:00:00 2001 From: Wojtek Date: Sun, 2 Aug 2026 22:10:17 -0400 Subject: [PATCH 4/4] Harden runner retirement evidence and migration --- README.md | 2 +- TESTING.md | 7 +- cmd/claw/scaffold_helpers.go | 4 + cmd/claw/scaffold_helpers_test.go | 16 + cmd/claw/skill_data/SKILL.md | 7 +- cmd/claw/spike_rollcall_test.go | 12 +- dockerfiles/nanoclaw-orchestrator/Dockerfile | 32 -- docs/PROJECT_STATE.md | 5 +- .../026-runner-adoption-and-retirement.md | 161 +++++---- docs/evidence/2026-08-03-runner-adoption.json | 107 ++++-- internal/build/build_test.go | 38 ++ internal/driver/registry.go | 30 +- internal/driver/retired_test.go | 7 +- internal/driver/shared/history.go | 8 + scripts/runner-adoption-snapshot/main.go | 327 ++++++++++++++---- scripts/runner-adoption-snapshot/main_test.go | 78 +++++ site/changelog.md | 2 +- site/guide/anatomy.md | 2 +- site/guide/architecture.md | 2 +- site/guide/drivers.md | 3 +- site/guide/what-is-clawdapus.md | 2 +- site/index.md | 2 +- skills/clawdapus/SKILL.md | 7 +- testdata/nanobot-stub/AGENTS.md | 4 +- 24 files changed, 596 insertions(+), 269 deletions(-) delete mode 100644 dockerfiles/nanoclaw-orchestrator/Dockerfile diff --git a/README.md b/README.md index 2163522..8a69cf4 100644 --- a/README.md +++ b/README.md @@ -353,7 +353,7 @@ Notes: block-beta columns 1 contract["Behavioral Contract\nread-only bind mount\nAGENTS.md — purpose, on the host\nSurvives full container compromise"] - runner["Runner\nOpenClaw · NanoClaw · Claude Code · custom"] + runner["Runner\nOpenClaw · Hermes · Nanobot · PicoClaw"] persona["Persona\nMemory · history · style · knowledge"] proxy["cllama — governance proxy\nIntercepts prompts outbound + responses inbound\nRunner never knows it's there"] diff --git a/TESTING.md b/TESTING.md index 465f3ed..22e0489 100644 --- a/TESTING.md +++ b/TESTING.md @@ -35,7 +35,7 @@ Docker, and a real Discord server. They are not CI tests. The two primary spike paths are: -- `TestSpikeRollCall`: the broad driver-parity validation path. Boots all 7 driver +- `TestSpikeRollCall`: the broad driver-parity validation path. Boots all 4 retained driver types plus `cllama` passthrough and `clawdash`, sends a Discord roll call, and verifies runtime-specific responses. - `TestSpikeComposeUp`: the deeper trading-desk validation path. Focuses on artifact @@ -54,9 +54,6 @@ cross-driver support. It uses [`examples/rollcall/`](./examples/rollcall/) and exercises: - `openclaw` -- `nullclaw` -- `microclaw` -- `nanoclaw` - `nanobot` - `picoclaw` - `hermes` @@ -65,7 +62,7 @@ exercises: ### What it validates -- Base images build for all 7 driver families +- Base images build for all 4 retained driver families - Agent images build from their `Clawfile`s - `claw up` succeeds on the rollcall pod - All agent containers converge to healthy/running state diff --git a/cmd/claw/scaffold_helpers.go b/cmd/claw/scaffold_helpers.go index ca2e1a2..abdd30b 100644 --- a/cmd/claw/scaffold_helpers.go +++ b/cmd/claw/scaffold_helpers.go @@ -11,6 +11,7 @@ import ( "strings" "unicode" + "github.com/mostlydev/clawdapus/internal/driver" "github.com/mostlydev/clawdapus/internal/driver/hermes" ) @@ -160,6 +161,9 @@ func parseClawType(value string) (string, error) { case "openclaw", "hermes", "nanobot", "picoclaw", "generic": return v, nil default: + if err := driver.RetirementError(v); err != nil { + return "", err + } return "", fmt.Errorf("invalid claw type %q (allowed: %s)", value, strings.Join(scaffoldClawTypes, ", ")) } } diff --git a/cmd/claw/scaffold_helpers_test.go b/cmd/claw/scaffold_helpers_test.go index 50f03a5..fb7c307 100644 --- a/cmd/claw/scaffold_helpers_test.go +++ b/cmd/claw/scaffold_helpers_test.go @@ -42,6 +42,22 @@ func TestParseClawTypeRejectsUnknownValue(t *testing.T) { } } +func TestParseClawTypeRejectsRetiredValuesWithMigrationGuidance(t *testing.T) { + for _, clawType := range []string{"nanoclaw", "microclaw", "nullclaw"} { + t.Run(clawType, func(t *testing.T) { + _, err := parseClawType(clawType) + if err == nil { + t.Fatalf("expected %s to be rejected", clawType) + } + for _, want := range []string{clawType, "retired", "ADR-026", `CLAW_TYPE "hermes"`} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("retirement error %q does not contain %q", err, want) + } + } + }) + } +} + func TestDefaultBaseImageForClawType(t *testing.T) { tests := []struct { name string diff --git a/cmd/claw/skill_data/SKILL.md b/cmd/claw/skill_data/SKILL.md index 507b8c4..a52262f 100644 --- a/cmd/claw/skill_data/SKILL.md +++ b/cmd/claw/skill_data/SKILL.md @@ -349,11 +349,11 @@ The policy is tunable from the pod YAML via `x-claw.tool-policy` (service level) ## Communication Tools Contract -All 7 runtimes enforce private thinking + deliberate delivery — agent reasoning never reaches Discord automatically. +All four drivers compile mention-only channel consumption so an unaddressed bot message does not create a feedback loop. - **Hermes**: `HERMES_TOOL_ONLY_MODE=1` injected when Discord handles are present; runtime patches prefer `send_message`, suppress duplicate final text after a successful `send_message`, and fall back to final-text delivery rather than silently dropping replies - **OpenClaw**: enforced natively -- **NullClaw, MicroClaw, NanoClaw, NanoBot, PicoClaw**: `discord-responder.sh` passes a `send_message` tool to the LLM; only posts to Discord when the tool is called +- **NanoBot and PicoClaw**: generated channel configuration enables each runner's mention-only equivalent CLAWDAPUS.md includes a `## Communication Tools` section with private-thinking policy whenever handles are configured. @@ -430,7 +430,6 @@ When the aggregate cap drops a feed the model sees an explicit `--- FEED: | `compose.generated.yml` | Final compose with all enforcement | Next to claw-pod.yml | | `CLAWDAPUS.md` | Per-agent infrastructure map | Mounted into container | | `AGENTS.effective.md` | Merged contract + CLAWDAPUS.md (OpenClaw and Hermes cllama context) | Mounted into container or cllama context | -| `CLAUDE.md` | Combined contract + CLAWDAPUS.md (NanoClaw) | Mounted into container | | `openclaw.json` | Generated runner config (OpenClaw) | Bind-mounted directory | | `config.yaml` / `.env` | Generated runner config (Hermes) | Bind-mounted directory | | `jobs.json` | Cron schedule for INVOKE tasks | Runner state directory | @@ -444,7 +443,7 @@ When the aggregate cap drops a feed the model sees an explicit `--- FEED: |--------|-----------|--------|--------------|-------| | OpenClaw | `openclaw` | OpenClaw | JSON5 Go-native patching -> `openclaw.json` | Primary driver. Read-only container. Docker exec health probe. | | Hermes | `hermes` | Hermes (Python) | `config.yaml` + `.env` | Discord/Telegram/Slack. `HERMES_TOOL_ONLY_MODE`. Requires at least one handle. | -| NanoBot | `nanobot` | Nanobot (Node.js) | `config.json` | Cron via `jobs.json`. Merged AGENTS.md. | +| NanoBot | `nanobot` | Nanobot (Python) | `config.json` | Cron via `jobs.json`. Merged AGENTS.md. | | PicoClaw | `picoclaw` | PicoClaw | `config.json` | HTTP `/health` + `/ready` probe. Read-only container. | Retired (ADR-026): `nanoclaw`, `microclaw`, `nullclaw` — these `CLAW_TYPE`s fail `claw up` with a migration message. diff --git a/cmd/claw/spike_rollcall_test.go b/cmd/claw/spike_rollcall_test.go index 8659687..a91b00d 100644 --- a/cmd/claw/spike_rollcall_test.go +++ b/cmd/claw/spike_rollcall_test.go @@ -182,8 +182,7 @@ func TestSpikeRollCall(t *testing.T) { { // nb-roll carries the anthropic-messages ingress surface for the // default run. The retired nullclaw/nanoclaw stubs used to cover - // it (ADR-026); pc-roll also exercises it but is gated behind - // CLAW_SPIKE_ENABLE_PICOCLAW while #137 is open. Stubs send the + // it (ADR-026). Stubs send the // bare provider/model ref directly via curl, so we must use a // model name that Anthropic actually recognises today // (claude-sonnet-4 alone is no longer a valid alias upstream). @@ -195,17 +194,14 @@ func TestSpikeRollCall(t *testing.T) { requireKeys: []string{"ANTHROPIC_API_KEY"}, }, { - // pc-roll is currently broken upstream — picoclaw's gateway binary - // rejects a port=0 config pre-check that the clawdapus picoclaw - // driver does not populate. Reproduces on master, tracked in #137. - // Gated behind CLAW_SPIKE_ENABLE_PICOCLAW so it skips by default - // instead of failing the suite while #137 is open. + // #137 fixed the upstream gateway-port regression; keep PicoClaw in + // the default matrix so rollcall covers every retained driver. name: "pc-roll", runtime: "picoclaw", proxyFormat: "anthropic", proxyModel: "anthropic/claude-sonnet-4-6", expectedSurface: "anthropic-messages", - requireKeys: []string{"ANTHROPIC_API_KEY", "CLAW_SPIKE_ENABLE_PICOCLAW"}, + requireKeys: []string{"ANTHROPIC_API_KEY"}, }, { name: "hm-roll", diff --git a/dockerfiles/nanoclaw-orchestrator/Dockerfile b/dockerfiles/nanoclaw-orchestrator/Dockerfile deleted file mode 100644 index b51b71d..0000000 --- a/dockerfiles/nanoclaw-orchestrator/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -# NanoClaw Orchestrator — built from stock nanoclaw + Clawdapus governance patch -# -# Clones upstream nanoclaw, applies a small patch to forward ANTHROPIC_BASE_URL -# and CLAW_NETWORK to ephemeral agent-runner containers, then builds. -# No fork required. -ARG NANOCLAW_REF=main -ARG NANOCLAW_REPO=https://github.com/qwibitai/nanoclaw.git - -FROM node:22-slim AS builder -ARG NANOCLAW_REF -ARG NANOCLAW_REPO -RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/* -WORKDIR /src -RUN git clone --depth 1 --branch ${NANOCLAW_REF} ${NANOCLAW_REPO} . -COPY governance.patch /tmp/governance.patch -RUN git apply /tmp/governance.patch -RUN npm ci -RUN npx tsc - -FROM node:22-slim -COPY --from=docker:27-cli /usr/local/bin/docker /usr/local/bin/docker -RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/* -WORKDIR /app -COPY --from=builder /src/dist/ ./dist/ -COPY --from=builder /src/node_modules/ ./node_modules/ -COPY --from=builder /src/package.json ./ -RUN npm rebuild better-sqlite3 -COPY --from=builder /src/container/ ./container/ -RUN mkdir -p /workspace/groups/main /workspace/data /workspace/store -RUN rm -rf /var/lib/apt/lists/* && apt-get purge -y python3 make g++ && apt-get autoremove -y || true -WORKDIR /workspace -ENTRYPOINT ["node", "/app/dist/index.js"] diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 934f909..ed4398e 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -7,7 +7,7 @@ of them — do not duplicate their content, point to them. Maintenance: when a capability lands or a release cuts, update the relevant row here in the same change. Trust order when sources disagree: current code → tests → examples → ADRs → plans. -Last reconciled: 2026-05-31 (against `master` @ v0.21.0, issue #280). +Last reconciled: 2026-08-03 (issue #353 runner-retirement audit). Status legend: **Shipped** (implemented and in use) · **Partial** (some shipped; gap noted) · **Planned** (designed, not implemented) · **External** (depends on an out-of-tree component, @@ -42,6 +42,7 @@ e.g. an org's Master Claw policy). | 022 | Infra Image Lifecycle & Four-Verb Surface | Shipped | `claw pull/build/up/down`; `internal/infraimages/release_manifest.go` | | 023 | Explicit cllama Ingress Surface Matrix | Shipped | `internal/cllama/ingress.go` compiles OpenAI Chat Completions at `/v1/chat/completions` and Anthropic Messages at `/v1/messages`. Anthropic-surface providers: `anthropic`, `synthetic`, `minimax-portal`, `kimi-coding`, `cloudflare-ai-gateway`, `xiaomi`; other providers default to OpenAI chat. Runtime dispatch treats `/v1/messages` as Anthropic and other POST paths as OpenAI. | | 024 | Runner Base Refresh from Upstream | Shipped | `claw pull` runner refresh; `built-against`/`image-id`/`recipe-sha` labels | +| 026 | Runner Adoption and Retirement | Shipped | four retained drivers; retired types fail closed with Hermes migration guidance | *(ADR-005 does not exist.)* @@ -65,7 +66,7 @@ e.g. an org's Master Claw policy). | claw-api + Master Claw (ADR-012/015) | Partial | manifesto, what-is, architecture | Budget caps enforced by cllama; policy decisions external; model-restrict enforcement partial | | Social topology / HANDLE (ADR-003/016) | Shipped | social-topology | No | | Persona materialization | Shipped | anatomy | No | -| 7 runner drivers | Shipped | drivers | No | +| 4 runner drivers | Shipped | drivers | No | | Context feeds (ADR-013) | Shipped | surfaces-and-skills, cllama, social-topology | No | | `TRACK` / recipe / bake | **Planned** | what-is roadmap, architecture | Correctly future, label it clearly | | Drift scoring | **External/Planned** | what-is, cllama, cli, README, manifesto, architecture | No built-in score; docs mark it external/planned | diff --git a/docs/decisions/026-runner-adoption-and-retirement.md b/docs/decisions/026-runner-adoption-and-retirement.md index 882b8f1..21c223d 100644 --- a/docs/decisions/026-runner-adoption-and-retirement.md +++ b/docs/decisions/026-runner-adoption-and-retirement.md @@ -1,17 +1,17 @@ # ADR-026: Runner Adoption Measurement and Driver Retirement Policy **Date:** 2026-08-03 -**Status:** Proposed +**Status:** Accepted **Depends on:** ADR-024 (Runner Base Refresh From Upstream) **Tracks:** #353 (audit adoption and retire stagnant runner integrations) -**Evidence snapshot:** 2026-08-03T00:00Z — see `scripts/runner-adoption-snapshot/` and `docs/evidence/2026-08-03-runner-adoption.json` +**Evidence snapshot:** 2026-08-03T01:58Z — see `scripts/runner-adoption-snapshot/` and `docs/evidence/2026-08-03-runner-adoption.json` ## Context Clawdapus carries seven runner drivers: OpenClaw, Hermes, Nanobot, NanoClaw, PicoClaw, MicroClaw, and NullClaw. Each one is a standing cost — compatibility with an upstream we do not control, image refresh, documentation, fixtures, a conformance shape in `examples/rollcall/`, and a share of every -driver-generic bug fix. `CLAUDE.md` records the multiplier plainly: "Bug fixes in one driver often +driver-generic bug fix. `AGENTS.md` records the multiplier plainly: "Bug fixes in one driver often apply to all 7." The request that opened #353 was to retire runners "not seeing an increase in adoption over time." @@ -41,10 +41,9 @@ stars, total downloads, and fork *counts*, a bucketed fork rate is not cumulativ a weak proxy for users, but it is the only non-cumulative adoption series obtainable for every runner, and it is reproducible from a public endpoint. -Two secondary sources corroborate: PyPI download history (available for `nanobot-ai` only, and the -one true adoption time series in the set) and Docker Hub `pull_count` (available for PicoClaw only, -because ghcr.io publishes no public pull counts — an asymmetry that makes OpenClaw and Hermes images -non-comparable on that axis). +Secondary sources corroborate where available: PyPI download history (`nanobot-ai`), Docker Hub +`pull_count` (PicoClaw), and cumulative GitHub release-asset downloads. ghcr.io publishes no public +pull counts, so container pulls are not comparable across the cohort. One identification note, since it cost time: PyPI publishes no project URLs for `nanobot-ai`, so Nanobot's upstream is not discoverable from the package page. It was confirmed as `HKUDS/nanobot` by @@ -57,48 +56,45 @@ evidence below contains a case that proves the point. ## Evidence -New forks per 30-day window, newest first, snapshot 2026-08-03T00:00Z: +New forks per 30-day window, newest first, snapshot 2026-08-03T01:58Z: | runner | 0-30d | 30-60d | 60-90d | 90-120d | newest/oldest | recent60/prior60 | commits QoQ | latest release | |---|---|---|---|---|---|---|---|---| -| Hermes | 6043 | 7598 | 10438 | 16303 | 0.37 | 0.51 | **+76%** | 2026-07-30 | -| OpenClaw | 1805 | 2187 | 3758 | 6995 | 0.26 | 0.37 | **+8%** | 2026-08-02 | -| Nanobot | 366 | 316 | 482 | 811 | 0.45 | 0.53 | -35% | 2026-07-25 | -| PicoClaw | 315 | 102 | 156 | 305 | **1.03** | **0.90** | **-75%** | 2026-07-02 (`nightly`) | -| NanoClaw | 167 | 223 | 412 | 2355 | **0.07** | 0.14 | -29% | 2026-08-01 | +| Hermes | 6036 | 7587 | 10442 | 16307 | 0.37 | 0.51 | **+86%** | 2026-07-30 | +| OpenClaw | 1807 | 2182 | 3756 | 6980 | 0.26 | 0.37 | **+6%** | 2026-08-02 | +| Nanobot | 366 | 314 | 480 | 813 | 0.45 | 0.53 | -33% | 2026-07-25 | +| PicoClaw | 315 | 102 | 154 | 306 | **1.03** | **0.91** | **-76%** | 2026-07-02 (`nightly`) | +| NanoClaw | 167 | 222 | 413 | 2346 | **0.07** | 0.14 | -32% | 2026-08-01 | | NullClaw | 32 | 22 | 44 | 44 | 0.73 | 0.61 | -89% | **2026-05-29** | | MicroClaw | **0** | 3 | 10 | 8 | **0.00** | 0.17 | -91% | 2026-08-01 | -Supporting figures: stars — OpenClaw 384956, Hermes 224323, Nanobot 46521, NanoClaw 30410, -PicoClaw 29799, NullClaw 7987, MicroClaw 730. Forks — OpenClaw 80901, Hermes 43378, NanoClaw 12871, -PicoClaw 4381, NullClaw 929, MicroClaw 132. Distribution scale — -OpenClaw ~123k downloads/release, PicoClaw ~33k/release plus 210714 Docker Hub pulls, Hermes and -NullClaw ~2k/release, MicroClaw ~30-95/release, NanoClaw not asset-distributed. `nanobot-ai` PyPI -monthly downloads — Feb 50395, Mar 79326, Apr 53246, May 33854, Jun 33446, Jul 68201. MicroClaw's -public Events API retains 169 events from 2026-07-04 including 11 `WatchEvent`s, so it is receiving -new stars and is not literally inert. +Supporting figures: stars — OpenClaw 384960, Hermes 224342, Nanobot 46522, NanoClaw 30411, +PicoClaw 29799, NullClaw 7987, MicroClaw 730. Forks — OpenClaw 80906, Hermes 43387, Nanobot 8231, +NanoClaw 12871, PicoClaw 4381, NullClaw 929, MicroClaw 132. Cumulative GitHub release-asset +downloads — OpenClaw 1825617, Hermes 25561, PicoClaw 227049, NullClaw 13229, MicroClaw 12161, +and none for Nanobot or NanoClaw. PicoClaw also has 210807 Docker Hub pulls. `nanobot-ai` PyPI +monthly downloads — Mar 79326, Apr 53246, May 33854, Jun 33446, Jul 68201. The table above is the committed collector's output. Those figures were also collected independently -by a second agent using separately written tooling, which agreed exactly on five of seven runners -(Nanobot 366, NanoClaw 167, PicoClaw 315, NullClaw 32, MicroClaw 0) and to within three forks on -OpenClaw and Hermes, the difference being live drift between snapshots taken minutes apart. Two +by a second agent using separately written tooling, which agreed exactly on the low-volume boundary +(MicroClaw 0 and NullClaw 32) and within live drift on the larger projects. Two collectors reaching the same answer is a materially stronger basis for deleting a driver than either -run alone, and it is the reason the retirements below are stated without hedging. +run alone. It establishes that the fork counts are not artifacts of one implementation; it does not +turn those proxy counts into Clawdapus usage telemetry. ### Two findings that determine the policy -**Every runner in the cohort has a declining fork rate.** Including OpenClaw (0.37) and Hermes -(0.51), which are unambiguous keeps on every other axis and are the two runners the project is built -around. Post-launch decay is universal here. A rule of the form "retire what is not increasing" -therefore selects all seven drivers, including the two the product depends on. The rule must be a -**floor**, not a **derivative**. +**A derivative is not a useful retirement boundary.** Six runners have declining fork rates, +including OpenClaw (0.26) and Hermes (0.37), which are unambiguous keeps on every other axis. +PicoClaw is the sole flat-to-rising exception. A literal rule of “retire what is not increasing” +would therefore delete six of seven drivers, including the two the product is built around. Recent +absolute activity and corroborating scale matter more than the sign of the derivative. **PicoClaw has the best adoption retention in the cohort and the worst maintenance trend.** Its fork rate is the only one flat-to-rising (1.03 newest/oldest, 0.90 on 60-day windows) while its commit volume fell 75%. The two metrics return opposite verdicts, and the adoption metric is the one that answers the question actually being asked. Any policy that had used commit velocity as the -deciding signal would have deleted the healthiest-retaining runner in the set. This is the concrete -reason the policy below separates the two rules and never collapses them. +deciding signal would have deleted the healthiest-retaining runner in the set. ## Decision @@ -108,63 +104,59 @@ cost**, with the adoption evidence used to decide which runners are defensibly w worth being explicit that this is a broader basis than adoption alone, rather than pretending a pure adoption rule produced this set. -A runner is retained only if it satisfies **all three** conditions: +The evidence is a review boundary, not an automatic three-clause deletion formula. Applying a +conjunction after seeing the rows would be post-hoc: NanoClaw's lack of release assets is missing +corroboration, not evidence of missing users, while NullClaw has measurable adoption despite its +maintenance signal. The decision is therefore recorded at its real level of judgment: -1. **Adoption floor.** New forks in the trailing 30-day window are materially distinguishable from - zero relative to the cohort. Threshold for this snapshot: at least 10. -2. **Upstream viability.** The upstream has shipped a release within 60 days, or its commit volume - has not collapsed quarter-over-quarter. Both failing together is disqualifying. -3. **Independent corroboration.** Adoption is visible on at least one distribution channel other - than GitHub social counts — package downloads, image pulls, or release assets — so the figure can - be checked against something. - -Requiring all three is what makes the rule survive the two findings above. Condition 1 is a floor -rather than a derivative, because every runner's fork rate is declining. Condition 2 is separate from -adoption and cannot on its own delete a runner people use. Condition 3 exists because GitHub stars -and forks are the metrics we trust least and the only ones available for every project. +1. **Keep a runner when the public case for carrying it is clear.** Absolute recent fork activity is + the common floor; package, image, or release distribution is corroboration where available. +2. **Treat missing corroboration and maintenance uncertainty as ambiguity, not as negative adoption + evidence.** Those signals can justify declining the integration's maintenance cost, but must not + be rewritten as “no users.” +3. **Record each retirement independently.** A future audit re-runs the evidence; it does not + mechanically delete a runner because one proxy crossed a tuned threshold. ### Verdicts -| runner | floor | viability | corroboration | verdict | -|---|---|---|---|---| -| OpenClaw | 1805 | release 2026-08-02, +8% | ~123k downloads/release | **retain** | -| Hermes | 6043 | release 2026-07-30, +76% | ~2k downloads/release | **retain** | -| Nanobot | 366 | release 2026-07-25, -35% | PyPI 68201/month | **retain** | -| PicoClaw | 315 | commits -75% but released 2026-07-02 | 210714 Docker Hub pulls | **retain** | -| NanoClaw | 167 | release 2026-08-01, -29% | **none** | **retire** | -| NullClaw | 32 | **no release in 64 days and -89%** | ~2k downloads/release | **retire** | -| MicroClaw | **0** | release 2026-08-01, -91% | ~30-95 downloads/release | **retire** | - -**MicroClaw fails the adoption floor.** Zero new forks in the trailing 30 days, 0.00 retention, 730 -stars, and tens of downloads per release — three orders of magnitude below PicoClaw. It is +| runner | public evidence | uncertainty | decision | +|---|---|---|---| +| OpenClaw | 1807 forks/30d; 1.8m release-asset downloads | none material | **retain** | +| Hermes | 6036 forks/30d; 25.5k release-asset downloads | none material | **retain** | +| Nanobot | 366 forks/30d; PyPI 68201/month in July | download series is choppy | **retain** | +| PicoClaw | 315 forks/30d; 210807 Docker pulls; best retention | commits down 76% | **retain** | +| NanoClaw | 167 forks/30d; 30411 stars | no independent distribution signal; 0.07 retention | **retire** | +| NullClaw | 32 forks/30d; 13.2k release-asset downloads | no release in 64 days; commits down 89% | **retire** | +| MicroClaw | 0 forks/30d; 730 stars | minimal public adoption despite active releases | **retire** | + +**MicroClaw has minimal public adoption.** Zero new forks in the trailing 30 days, 0.00 retention, 730 +stars, and 12161 cumulative release-asset downloads — orders of magnitude below PicoClaw. It is *actively maintained*, four releases in the ten days before the snapshot, which is precisely the -point: it has a maintainer and no users. That is what an adoption rule catches and a maintenance rule -would have missed. It is not literally inert — its Events API shows 11 `WatchEvent`s in the same -window — which is why the wording throughout is "no material adoption growth" rather than "no -growth". +point: maintenance activity does not establish adoption. The evidence supports “minimal public +adoption,” not “no users.” -**NullClaw fails upstream viability.** No release since 2026-05-29 and commit volume down 89%. Its -adoption is real (7987 stars, ~2k downloads per release, 32 new forks), so this is a decision to stop -carrying an upstream that has stopped moving, not a claim that nobody uses it. +**NullClaw's maintenance signal is weak.** No release since 2026-05-29 and commit volume down 89%. Its +adoption is real (7987 stars, 13229 cumulative release-asset downloads, 32 new forks), so this is a +maintenance-cost decision under uncertainty, not a claim that nobody uses it or that the upstream +has stopped entirely. -**NanoClaw fails corroboration.** It is the only runner in the set with no asset distribution at all, -so its 30410 stars cannot be checked against anything independent. It also carries the steepest -adoption decay in the cohort by a wide margin — 0.07 newest/oldest, 0.14 across 60-day windows, -against a next-worst of 0.26. Absent corroboration, a decay that steep is the whole picture rather -than one signal among several. +**NanoClaw remains ambiguous.** No independent public distribution metric was found, so its 30411 +stars cannot be checked against package downloads, image pulls, or release-asset downloads. That +absence is ambiguity, not negative evidence. NanoClaw also carries the steepest adoption decay in +the cohort by a wide margin — 0.07 newest/oldest, 0.14 across 60-day windows, against a next-worst of +0.26. Without a corroborating signal, the operator declined the cost of maintaining the integration. **PicoClaw is retained, and this is the load-bearing case.** On maintenance it looks like the worst -runner in the set: commits down 75%, eight commits in the trailing four weeks, and its most recent +runner in the set: commits down 76%, and its most recent tag is a `nightly`. On adoption it is the *best* — the only runner whose fork rate is flat-to-rising -(1.03 newest/oldest, 0.90 across 60-day windows), plus 210714 Docker Hub pulls and ~33k downloads per -release. Two metrics, opposite verdicts. Any rule that let maintenance activity decide would have -deleted the healthiest-retaining runner in the cohort while keeping weaker ones. Condition 2 is -therefore written as a conjunction — a slowing upstream is not disqualifying on its own. +(1.03 newest/oldest, 0.91 across 60-day windows), plus 210807 Docker Hub pulls and 227049 cumulative +release-asset downloads. Two metrics, opposite verdicts. Any rule that let maintenance activity +decide would have deleted the healthiest-retaining runner in the cohort while keeping weaker ones. -**Removed `CLAW_TYPE` values fail closed** with a validation error naming the retired runner, why it -went, and the closest supported runner (`internal/driver/registry.go`). A `claw up` that fails with a -clear message is compile-time behavior consistent with the compiler contract; silently changing -behavior is not. +**Removed `CLAW_TYPE` values fail closed for one release** with a validation error naming the retired +runner, ADR-026, and Hermes as the supported migration target (`internal/driver/registry.go`). A +`claw up` that fails with a clear message is compile-time behavior consistent with the compiler +contract; silently changing behavior is not. **Revisit policy:** re-run the collector and re-evaluate at each minor release, or whenever a retained runner's upstream goes 60+ days without a release. @@ -175,11 +167,11 @@ retained runner's upstream goes 60+ days without a release. 2784, picoclaw 1252, nullclaw 1125, nanobot 974, microclaw 847, nanoclaw 597. Retiring NanoClaw, MicroClaw, and NullClaw reclaims ~2,600 lines of driver code plus their examples, fixtures, and docs. What it actually costs is **three conformance shapes**. `examples/rollcall/` was a seven-driver -pod and `CLAUDE.md` requires it remain a full spike test; `TestSpikeRollCall` is the primary proof +pod and `AGENTS.md` requires it remain a full spike test; `TestSpikeRollCall` is the primary proof that cllama proxy enforcement holds across *heterogeneous* runner shapes — distinct config formats, `HOME` handling, mention-only semantics, cron paths. Every shape removed is a shape that can no -longer catch a driver-generic regression, and `CLAUDE.md` records that "bug fixes in one driver often -apply to all 7". The rollcall fixture must be **revised to keep full conformance coverage across all +longer catch a driver-generic regression; the pre-retirement guide recorded that bug fixes commonly +applied across all seven. The rollcall fixture must be **revised to keep full conformance coverage across all four retained drivers**, not merely have three services deleted from it. The offsetting gain is the operator's stated one: every driver-generic fix now has four targets instead of seven, and none of the remaining four is a runner we cannot corroborate real use for. @@ -192,7 +184,7 @@ survives in three distinct kinds of place, and they get different treatment: `cmd/claw/skill_data/`, and generated-artifact test expectations. These describe what Clawdapus supports today; leaving a retired runner in them is a false claim. - **Historical record — leave alone.** `docs/plans/`, superseded ADRs, and existing `site/changelog.md` - entries. These are dated accounts of what was true when written. `CLAUDE.md` already forbids + entries. These are dated accounts of what was true when written. `AGENTS.md` already forbids rewriting historical changelog entries; the same reasoning covers plans and prior ADRs. A plan from 2026-03 that describes bringing MicroClaw to driver parity remains a true statement about 2026-03. Editing it to erase the runner falsifies the record and destroys the context a future @@ -209,5 +201,6 @@ miss this runner", not "do our users depend on this driver". The entire decision substitution, and if driver-level usage telemetry ever exists it supersedes this evidence model. **Reproducibility.** `scripts/runner-adoption-snapshot/` re-collects every surviving metric and -writes a dated JSON artifact under `docs/evidence/`. The classification thresholds live in this ADR, -not in the collector, so a future audit can re-run the data without inheriting today's judgement. +writes a dated JSON artifact under `docs/evidence/`. The collector captures fork windows, commit +windows, release cadence and asset downloads, Docker Hub pulls where public, and PyPI history where +available. A future audit can refresh the evidence without inheriting today's judgment as code. diff --git a/docs/evidence/2026-08-03-runner-adoption.json b/docs/evidence/2026-08-03-runner-adoption.json index 19b05d0..4af520f 100644 --- a/docs/evidence/2026-08-03-runner-adoption.json +++ b/docs/evidence/2026-08-03-runner-adoption.json @@ -1,5 +1,5 @@ { - "captured_at": "2026-08-03T00:00:00Z", + "captured_at": "2026-08-03T01:58:23.798226Z", "window_days": 30, "window_count": 4, "limitations": [ @@ -12,49 +12,66 @@ { "driver": "openclaw", "repo": "openclaw/openclaw", - "stars": 384956, - "forks": 80902, - "pushed_at": "2026-08-03T01:06:49Z", + "stars": 384960, + "forks": 80906, + "pushed_at": "2026-08-03T01:58:22Z", "latest_release": "2026-08-02T08:11:31Z", "fork_windows": [ - 1805, - 2187, - 3758, - 6995 - ] + 1807, + 2182, + 3756, + 6980 + ], + "commit_windows_90d": [ + 34524, + 32475 + ], + "release_count": 229, + "release_asset_downloads": 1825617 }, { "driver": "hermes", "repo": "NousResearch/hermes-agent", - "stars": 224327, - "forks": 43378, - "pushed_at": "2026-08-03T00:20:48Z", + "stars": 224342, + "forks": 43387, + "pushed_at": "2026-08-03T01:53:26Z", "latest_release": "2026-07-30T23:45:37Z", "fork_windows": [ - 6043, - 7598, - 10438, - 16303 - ] + 6036, + 7587, + 10442, + 16307 + ], + "commit_windows_90d": [ + 13124, + 7074 + ], + "release_count": 23, + "release_asset_downloads": 25561 }, { "driver": "nanobot", "repo": "HKUDS/nanobot", "pypi": "nanobot-ai", - "stars": 46521, + "stars": 46522, "forks": 8231, - "pushed_at": "2026-08-02T15:25:22Z", + "pushed_at": "2026-08-03T01:51:35Z", "latest_release": "2026-07-25T08:08:47Z", "fork_windows": [ 366, - 316, - 482, - 811 + 314, + 480, + 813 + ], + "commit_windows_90d": [ + 1479, + 2192 ], + "release_count": 19, "pypi_monthly": [ { "period": "2026-02", - "count": 50395 + "count": 49581 }, { "period": "2026-03", @@ -86,16 +103,21 @@ "driver": "nanoclaw", "repo": "nanocoai/nanoclaw", "note": "canonical repo redirected from qwibitai/nanoclaw", - "stars": 30410, + "stars": 30411, "forks": 12871, "pushed_at": "2026-08-02T14:13:24Z", "latest_release": "2026-08-01T20:46:31Z", "fork_windows": [ 167, - 223, - 412, - 2355 - ] + 222, + 413, + 2346 + ], + "commit_windows_90d": [ + 888, + 1310 + ], + "release_count": 5 }, { "driver": "picoclaw", @@ -108,9 +130,16 @@ "fork_windows": [ 315, 102, - 156, - 305 - ] + 154, + 306 + ], + "commit_windows_90d": [ + 506, + 2077 + ], + "release_count": 16, + "release_asset_downloads": 227049, + "docker_hub_pulls": 210807 }, { "driver": "microclaw", @@ -124,7 +153,13 @@ 3, 10, 8 - ] + ], + "commit_windows_90d": [ + 91, + 996 + ], + "release_count": 161, + "release_asset_downloads": 12161 }, { "driver": "nullclaw", @@ -138,7 +173,13 @@ 22, 44, 44 - ] + ], + "commit_windows_90d": [ + 260, + 2475 + ], + "release_count": 30, + "release_asset_downloads": 13229 } ] } diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 6866ae7..0d1a5ec 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -5,12 +5,27 @@ import ( "os" "path/filepath" "reflect" + "sort" "strings" "testing" + "github.com/mostlydev/clawdapus/internal/driver" "github.com/mostlydev/clawdapus/internal/driver/hermes" ) +func TestBuiltInDriverSetMatchesSupportedContract(t *testing.T) { + registered := driver.Registered() + got := make([]string, 0, len(registered)) + for name := range registered { + got = append(got, name) + } + sort.Strings(got) + want := []string{"hermes", "nanobot", "openclaw", "picoclaw"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("registered built-in drivers = %v, want %v", got, want) + } +} + func TestGenerateWritesDockerfile(t *testing.T) { dir := t.TempDir() clawfilePath := filepath.Join(dir, "Clawfile") @@ -75,6 +90,29 @@ AGENT CONTRACT.md } } +func TestGenerateRejectsRetiredClawTypesWithMigrationGuidance(t *testing.T) { + for _, clawType := range []string{"nanoclaw", "microclaw", "nullclaw"} { + t.Run(clawType, func(t *testing.T) { + dir := t.TempDir() + clawfilePath := filepath.Join(dir, "Clawfile") + input := "FROM alpine:latest\nCLAW_TYPE " + clawType + "\nAGENT AGENTS.md\n" + if err := os.WriteFile(clawfilePath, []byte(input), 0o644); err != nil { + t.Fatal(err) + } + + _, err := Generate(clawfilePath) + if err == nil { + t.Fatalf("expected Generate to reject retired CLAW_TYPE %q", clawType) + } + for _, want := range []string{clawType, "retired", "ADR-026", `CLAW_TYPE "hermes"`} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("retirement error %q does not contain %q", err, want) + } + } + }) + } +} + func TestGenerateAcceptsNanobotType(t *testing.T) { dir := t.TempDir() clawfilePath := filepath.Join(dir, "Clawfile") diff --git a/internal/driver/registry.go b/internal/driver/registry.go index bb7b9c4..ccbd342 100644 --- a/internal/driver/registry.go +++ b/internal/driver/registry.go @@ -16,14 +16,24 @@ func Register(name string, d Driver) { drivers[name] = d } -// retiredRunners maps a runner dropped by ADR-026 to the reason it went and -// the supported runner closest to it. A retired type fails compilation with -// this guidance rather than the generic unknown-driver message, so an operator -// on an old pod file learns what happened instead of guessing at a typo. -var retiredRunners = map[string]string{ - "nanoclaw": "no independent distribution channel to corroborate adoption, and the steepest adoption decay in the runner set; closest supported runner: nanobot", - "microclaw": "no material adoption growth; closest supported runner: picoclaw", - "nullclaw": "upstream is no longer viable (no release in over 60 days); closest supported runner: picoclaw", +const retiredRunnerMigrationTarget = "hermes" + +// retiredRunners records runner types deliberately removed by ADR-026. Keep +// this compatibility error for one release so old Clawfiles fail with an +// actionable migration instead of looking like typos. +var retiredRunners = map[string]struct{}{ + "nanoclaw": {}, + "microclaw": {}, + "nullclaw": {}, +} + +// RetirementError returns the canonical migration error for a retired runner. +// Other unknown names return nil and keep the generic unknown-driver path. +func RetirementError(name string) error { + if _, retired := retiredRunners[name]; !retired { + return nil + } + return fmt.Errorf("CLAW_TYPE %q was retired by ADR-026; migrate this Clawfile to CLAW_TYPE %q", name, retiredRunnerMigrationTarget) } func Lookup(name string) (Driver, error) { @@ -31,8 +41,8 @@ func Lookup(name string) (Driver, error) { defer mu.RUnlock() d, ok := drivers[name] if !ok { - if reason, retired := retiredRunners[name]; retired { - return nil, fmt.Errorf("CLAW_TYPE %q was retired in ADR-026: %s", name, reason) + if err := RetirementError(name); err != nil { + return nil, err } return nil, fmt.Errorf("unknown CLAW_TYPE %q: no registered driver", name) } diff --git a/internal/driver/retired_test.go b/internal/driver/retired_test.go index 289f284..6210a12 100644 --- a/internal/driver/retired_test.go +++ b/internal/driver/retired_test.go @@ -24,12 +24,9 @@ func TestLookupRejectsRetiredRunnersWithMigrationGuidance(t *testing.T) { if !strings.Contains(msg, "ADR-026") { t.Errorf("error should point at the decision record, got %q", msg) } - for _, retained := range []string{"openclaw", "hermes", "picoclaw", "nanobot"} { - if strings.Contains(msg, retained) { - return - } + if !strings.Contains(msg, `CLAW_TYPE "hermes"`) { + t.Errorf("error should name Hermes as the migration target, got %q", msg) } - t.Errorf("error should name at least one supported runner to migrate to, got %q", msg) }) } } diff --git a/internal/driver/shared/history.go b/internal/driver/shared/history.go index 2dab21b..13af635 100644 --- a/internal/driver/shared/history.go +++ b/internal/driver/shared/history.go @@ -53,6 +53,14 @@ func legacyPortableHistoryDirs(root string) []portableHistoryDirMapping { {srcPath: filepath.Join(root, "nanobot-home", "sessions"), dstRel: filepath.Join("nanobot", "sessions")}, {srcPath: filepath.Join(root, "picoclaw-home", "workspace", "sessions"), dstRel: filepath.Join("picoclaw", "sessions")}, {srcPath: filepath.Join(root, "picoclaw-home", "sessions"), dstRel: filepath.Join("picoclaw", "sessions")}, + // Retired runner paths remain importable so an operator can migrate + // without losing the portable history those drivers already wrote. + {srcPath: filepath.Join(root, "data", "working_dir", "sessions"), dstRel: filepath.Join("microclaw", "sessions")}, + {srcPath: filepath.Join(root, "data", "working_dir", "history"), dstRel: filepath.Join("microclaw", "history")}, + {srcPath: filepath.Join(root, "nullclaw-home", "sessions"), dstRel: filepath.Join("nullclaw", "sessions")}, + {srcPath: filepath.Join(root, "nullclaw-home", "history"), dstRel: filepath.Join("nullclaw", "history")}, + {srcPath: filepath.Join(root, "data", "sessions"), dstRel: filepath.Join("nanoclaw", "data-sessions")}, + {srcPath: filepath.Join(root, "store", "sessions"), dstRel: filepath.Join("nanoclaw", "store-sessions")}, } } diff --git a/scripts/runner-adoption-snapshot/main.go b/scripts/runner-adoption-snapshot/main.go index 8999708..c95ca2a 100644 --- a/scripts/runner-adoption-snapshot/main.go +++ b/scripts/runner-adoption-snapshot/main.go @@ -8,20 +8,16 @@ // stargazer timestamps are unavailable (the endpoint 404s for external repos) // and per-release downloads-per-day is confounded by post-publication decay. // -// Classification thresholds deliberately live in ADR-026, not here, so a future -// audit can refresh the data without inheriting today's judgement. +// Classification judgments deliberately live in ADR-026, not here, so a future +// audit can refresh the data without inheriting today's judgment. // // Usage: // // go run ./scripts/runner-adoption-snapshot -out docs/evidence // -// Requires an authenticated `gh` CLI on PATH. -// -// Fork pagination dominates the runtime: the window tally reads 100 forks per -// request until it passes the oldest window, so a runner with tens of thousands -// of recent forks costs hundreds of sequential API calls. A full 4x30d snapshot -// across all runners spends a few thousand requests against the 5000/hour core -// limit. Narrow -windows or -window-days when iterating. +// Requires an authenticated `gh` CLI on PATH. Fork-window boundaries are found +// with binary search over newest-first pages and cached across windows, so even +// large repositories take tens of requests rather than hundreds. package main import ( @@ -30,6 +26,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "os/exec" "path/filepath" @@ -70,17 +67,18 @@ type snapshot struct { type runnerResult struct { runner - Stars int `json:"stars,omitempty"` - Forks int `json:"forks,omitempty"` - Archived bool `json:"archived,omitempty"` - PushedAt string `json:"pushed_at,omitempty"` - LatestRelease string `json:"latest_release,omitempty"` - ForkWindows []int `json:"fork_windows,omitempty"` - PyPIMonthly []bucket `json:"pypi_monthly,omitempty"` - Errors []string `json:"errors,omitempty"` - // ForkWindowsTruncated marks counts that hit the page cap before reaching - // the oldest window. Such counts are lower bounds, not totals. - ForkWindowsTruncated bool `json:"fork_windows_truncated,omitempty"` + Stars int `json:"stars,omitempty"` + Forks int `json:"forks,omitempty"` + Archived bool `json:"archived,omitempty"` + PushedAt string `json:"pushed_at,omitempty"` + LatestRelease string `json:"latest_release,omitempty"` + ForkWindows []int `json:"fork_windows,omitempty"` + CommitWindows []int `json:"commit_windows_90d,omitempty"` + ReleaseCount int `json:"release_count,omitempty"` + AssetDownloads int `json:"release_asset_downloads,omitempty"` + DockerHubPulls int `json:"docker_hub_pulls,omitempty"` + PyPIMonthly []bucket `json:"pypi_monthly,omitempty"` + Errors []string `json:"errors,omitempty"` } type bucket struct { @@ -92,17 +90,16 @@ func main() { out := flag.String("out", "docs/evidence", "directory for the dated JSON artifact") windowDays := flag.Int("window-days", 30, "width of each fork-count window in days") windows := flag.Int("windows", 4, "number of consecutive windows to collect, newest first") - maxPages := flag.Int("max-pages", 0, "cap fork pages read per runner (0 = no cap); capped counts are marked truncated") flag.Parse() - if err := run(*out, *windowDays, *windows, *maxPages); err != nil { + if err := run(*out, *windowDays, *windows); err != nil { fmt.Fprintln(os.Stderr, "runner-adoption-snapshot:", err) os.Exit(1) } } -func run(outDir string, windowDays, windowCount, maxPages int) error { - now := time.Now().UTC().Truncate(24 * time.Hour) +func run(outDir string, windowDays, windowCount int) error { + now := time.Now().UTC() snap := snapshot{ CapturedAt: now, WindowDays: windowDays, @@ -115,22 +112,38 @@ func run(outDir string, windowDays, windowCount, maxPages int) error { }, } - for _, r := range runners { - result := runnerResult{runner: r} - if r.Repo != "" { - if err := collectRepo(&result, now, windowDays, windowCount, maxPages); err != nil { - result.Errors = append(result.Errors, err.Error()) + results := make([]runnerResult, len(runners)) + var wg sync.WaitGroup + for index, r := range runners { + wg.Add(1) + go func() { + defer wg.Done() + result := runnerResult{runner: r} + if r.Repo != "" { + if err := collectRepo(&result, now, windowDays, windowCount); err != nil { + result.Errors = append(result.Errors, err.Error()) + } } - } - if r.PyPI != "" { - monthly, err := collectPyPI(r.PyPI) - if err != nil { - result.Errors = append(result.Errors, err.Error()) + if r.PyPI != "" { + monthly, err := collectPyPI(r.PyPI) + if err != nil { + result.Errors = append(result.Errors, err.Error()) + } + result.PyPIMonthly = monthly } - result.PyPIMonthly = monthly - } - snap.Runners = append(snap.Runners, result) + if r.DockerHub != "" { + pulls, err := collectDockerHubPulls(r.DockerHub) + if err != nil { + result.Errors = append(result.Errors, err.Error()) + } else { + result.DockerHubPulls = pulls + } + } + results[index] = result + }() } + wg.Wait() + snap.Runners = results if err := os.MkdirAll(outDir, 0o755); err != nil { return fmt.Errorf("create %s: %w", outDir, err) @@ -149,7 +162,7 @@ func run(outDir string, windowDays, windowCount, maxPages int) error { return nil } -func collectRepo(result *runnerResult, now time.Time, windowDays, windowCount, maxPages int) error { +func collectRepo(result *runnerResult, now time.Time, windowDays, windowCount int) error { meta, err := ghJSON("repos/" + result.Repo) if err != nil { return fmt.Errorf("repo metadata: %w", err) @@ -159,54 +172,220 @@ func collectRepo(result *runnerResult, now time.Time, windowDays, windowCount, m result.Archived = boolField(meta, "archived") result.PushedAt = stringField(meta, "pushed_at") - if releases, err := ghArray("repos/" + result.Repo + "/releases?per_page=1"); err == nil && len(releases) > 0 { - result.LatestRelease = stringField(releases[0], "published_at") + latest, releaseCount, downloads, err := collectReleases(result.Repo) + if err != nil { + result.Errors = append(result.Errors, "releases: "+err.Error()) + } else { + result.LatestRelease = latest + result.ReleaseCount = releaseCount + result.AssetDownloads = downloads } - counts, truncated, err := forkWindows(result.Repo, now, windowDays, windowCount, maxPages) + counts, err := forkWindows(result.Repo, result.Forks, now, windowDays, windowCount) if err != nil { return fmt.Errorf("fork windows: %w", err) } result.ForkWindows = counts - result.ForkWindowsTruncated = truncated + + current, err := commitCount(result.Repo, now.AddDate(0, 0, -90), now) + if err != nil { + result.Errors = append(result.Errors, "current commit window: "+err.Error()) + } else { + previous, err := commitCount(result.Repo, now.AddDate(0, 0, -180), now.AddDate(0, 0, -90)) + if err != nil { + result.Errors = append(result.Errors, "previous commit window: "+err.Error()) + } else { + result.CommitWindows = []int{current, previous} + } + } return nil } -// forkWindows pages newest-first through forks and tallies each one into the -// window its creation timestamp falls in, stopping as soon as it reads past the -// oldest window. Repositories with very large fork counts make this the most -// expensive call in the collector, which is why it terminates early rather than -// paginating the whole list. -func forkWindows(repo string, now time.Time, windowDays, windowCount, maxPages int) ([]int, bool, error) { +type forkPageLoader func(page int) ([]map[string]any, error) + +func forkWindows(repo string, totalForks int, now time.Time, windowDays, windowCount int) ([]int, error) { + return forkWindowsWithLoader(totalForks, now, windowDays, windowCount, func(page int) ([]map[string]any, error) { + path := fmt.Sprintf("repos/%s/forks?sort=newest&per_page=100&page=%d", repo, page) + return ghArray(path) + }) +} + +// forkWindowsWithLoader finds each time boundary with binary search over the +// newest-first fork pages. Complete pages between boundaries do not need to be +// downloaded; only boundary pages are inspected and all fetched pages are +// cached across windows. +func forkWindowsWithLoader(totalForks int, now time.Time, windowDays, windowCount int, load forkPageLoader) ([]int, error) { + if windowDays <= 0 || windowCount <= 0 || totalForks < 0 { + return nil, fmt.Errorf("invalid fork-window configuration") + } counts := make([]int, windowCount) - horizon := now.AddDate(0, 0, -windowDays*windowCount) + if totalForks == 0 { + return counts, nil + } - for page := 1; ; page++ { - if maxPages > 0 && page > maxPages { - return counts, true, nil + pages := (totalForks + 99) / 100 + cache := make(map[int][]map[string]any) + page := func(number int) ([]map[string]any, error) { + if items, ok := cache[number]; ok { + return items, nil } - path := fmt.Sprintf("repos/%s/forks?sort=newest&per_page=100&page=%d", repo, page) - items, err := ghArray(path) + items, err := load(number) if err != nil { - return nil, false, err + return nil, err } - if len(items) == 0 { - return counts, false, nil + cache[number] = items + return items, nil + } + + countAfter := func(cutoff time.Time) (int, error) { + lo, hi := 1, pages + boundary := pages + 1 + for lo <= hi { + mid := lo + (hi-lo)/2 + items, err := page(mid) + if err != nil { + return 0, err + } + if len(items) == 0 { + boundary = mid + hi = mid - 1 + continue + } + last, err := time.Parse(time.RFC3339, stringField(items[len(items)-1], "created_at")) + if err != nil { + return 0, fmt.Errorf("page %d last fork timestamp: %w", mid, err) + } + if last.After(cutoff) { + lo = mid + 1 + } else { + boundary = mid + hi = mid - 1 + } + } + + if boundary == pages+1 { + lastPage, err := page(pages) + if err != nil { + return 0, err + } + return (pages-1)*100 + len(lastPage), nil + } + + items, err := page(boundary) + if err != nil { + return 0, err } + prefix := 0 for _, item := range items { created, err := time.Parse(time.RFC3339, stringField(item, "created_at")) if err != nil { - continue + return 0, fmt.Errorf("page %d fork timestamp: %w", boundary, err) } - if created.Before(horizon) { - // Forks are newest-first, so everything after this is older. - return counts, false, nil + if !created.After(cutoff) { + break } - if index, ok := windowIndex(now, created, windowDays, windowCount); ok { - counts[index]++ + prefix++ + } + return (boundary-1)*100 + prefix, nil + } + + previous, err := countAfter(now) + if err != nil { + return nil, err + } + for index := 0; index < windowCount; index++ { + cutoff := now.AddDate(0, 0, -windowDays*(index+1)) + cumulative, err := countAfter(cutoff) + if err != nil { + return nil, err + } + counts[index] = cumulative - previous + previous = cumulative + } + return counts, nil +} + +func collectReleases(repo string) (string, int, int, error) { + latest := "" + count := 0 + downloads := 0 + for page := 1; ; page++ { + releases, err := ghArray(fmt.Sprintf("repos/%s/releases?per_page=100&page=%d", repo, page)) + if err != nil { + return "", 0, 0, err + } + if page == 1 && len(releases) > 0 { + latest = stringField(releases[0], "published_at") + } + for _, release := range releases { + count++ + assets, _ := release["assets"].([]any) + for _, raw := range assets { + asset, _ := raw.(map[string]any) + downloads += intField(asset, "download_count") } } + if len(releases) < 100 { + return latest, count, downloads, nil + } + } +} + +func commitCount(repo string, since, until time.Time) (int, error) { + query := url.Values{} + query.Set("since", since.UTC().Format(time.RFC3339)) + query.Set("until", until.UTC().Format(time.RFC3339)) + query.Set("per_page", "1") + body, headers, err := ghResponse("repos/" + repo + "/commits?" + query.Encode()) + if err != nil { + return 0, err + } + var commits []map[string]any + if err := json.Unmarshal(body, &commits); err != nil { + return 0, err + } + if len(commits) == 0 { + return 0, nil + } + if last := lastPage(headers.Get("Link")); last > 0 { + return last, nil + } + return len(commits), nil +} + +func lastPage(link string) int { + for _, part := range strings.Split(link, ",") { + if !strings.Contains(part, `rel="last"`) { + continue + } + start := strings.Index(part, "<") + end := strings.Index(part, ">") + if start < 0 || end <= start { + return 0 + } + u, err := url.Parse(part[start+1 : end]) + if err != nil { + return 0 + } + var page int + _, _ = fmt.Sscanf(u.Query().Get("page"), "%d", &page) + return page + } + return 0 +} + +func collectDockerHubPulls(repo string) (int, error) { + body, err := httpGet("https://hub.docker.com/v2/repositories/" + repo + "/") + if err != nil { + return 0, fmt.Errorf("docker hub %s: %w", repo, err) + } + var payload struct { + PullCount int `json:"pull_count"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return 0, fmt.Errorf("docker hub %s: %w", repo, err) } + return payload.PullCount, nil } // collectPyPI returns monthly download totals excluding mirrors. This is the @@ -320,17 +499,21 @@ func ghArray(path string) ([]map[string]any, error) { } // gh calls the GitHub REST API directly, borrowing the `gh` CLI's credentials -// once rather than spawning a subprocess per request. Fork pagination issues -// hundreds of calls for a large repo, and per-call process startup dominated -// the runtime when this shelled out to `gh api` each time. +// once rather than spawning a subprocess per request. Even with boundary search, +// process startup would otherwise dominate the collector's runtime. func gh(path string) ([]byte, error) { + body, _, err := ghResponse(path) + return body, err +} + +func ghResponse(path string) ([]byte, http.Header, error) { token, err := githubToken() if err != nil { - return nil, err + return nil, nil, err } req, err := http.NewRequest(http.MethodGet, "https://api.github.com/"+strings.TrimPrefix(path, "/"), nil) if err != nil { - return nil, err + return nil, nil, err } req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("X-GitHub-Api-Version", "2022-11-28") @@ -339,17 +522,17 @@ func gh(path string) ([]byte, error) { } resp, err := githubClient.Do(req) if err != nil { - return nil, fmt.Errorf("GET %s: %w", path, err) + return nil, nil, fmt.Errorf("GET %s: %w", path, err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("GET %s: %w", path, err) + return nil, nil, fmt.Errorf("GET %s: %w", path, err) } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GET %s: status %s: %s", path, resp.Status, strings.TrimSpace(string(body))) + return nil, resp.Header, fmt.Errorf("GET %s: status %s: %s", path, resp.Status, strings.TrimSpace(string(body))) } - return body, nil + return body, resp.Header, nil } var ( diff --git a/scripts/runner-adoption-snapshot/main_test.go b/scripts/runner-adoption-snapshot/main_test.go index 1f1e77b..e0a218a 100644 --- a/scripts/runner-adoption-snapshot/main_test.go +++ b/scripts/runner-adoption-snapshot/main_test.go @@ -1,6 +1,8 @@ package main import ( + "fmt" + "reflect" "testing" "time" ) @@ -45,3 +47,79 @@ func TestWindowIndexRejectsNonPositiveConfiguration(t *testing.T) { t.Fatal("expected zero window count to be rejected") } } + +func TestForkWindowsBinarySearchesBoundariesAndCachesPages(t *testing.T) { + now := time.Date(2026, time.August, 3, 12, 0, 0, 0, time.UTC) + items := make([]map[string]any, 1000) + for index := range items { + // One future timestamp and one timestamp exactly at now exercise the + // upper bound. Six-hour spacing gives exactly 120 forks per 30 days. + created := now.Add(6*time.Hour - time.Duration(index)*6*time.Hour) + items[index] = map[string]any{"created_at": created.Format(time.RFC3339)} + } + + loads := 0 + got, err := forkWindowsWithLoader(len(items), now, 30, 4, func(page int) ([]map[string]any, error) { + loads++ + start := (page - 1) * 100 + if start >= len(items) { + return nil, nil + } + end := start + 100 + if end > len(items) { + end = len(items) + } + return items[start:end], nil + }) + if err != nil { + t.Fatal(err) + } + if want := []int{120, 120, 120, 120}; !reflect.DeepEqual(got, want) { + t.Fatalf("fork windows = %v, want %v", got, want) + } + if loads > 10 { + t.Fatalf("loaded %d pages; boundary search should cache and stay logarithmic", loads) + } +} + +func TestForkWindowsCountsPartialLastPageWhenAllForksAreRecent(t *testing.T) { + now := time.Date(2026, time.August, 3, 12, 0, 0, 0, time.UTC) + items := make([]map[string]any, 205) + for index := range items { + items[index] = map[string]any{"created_at": now.Add(-time.Duration(index) * time.Hour).Format(time.RFC3339)} + } + + got, err := forkWindowsWithLoader(len(items), now, 30, 1, func(page int) ([]map[string]any, error) { + start := (page - 1) * 100 + if start >= len(items) { + return nil, nil + } + end := start + 100 + if end > len(items) { + end = len(items) + } + return items[start:end], nil + }) + if err != nil { + t.Fatal(err) + } + if want := []int{205}; !reflect.DeepEqual(got, want) { + t.Fatalf("fork windows = %v, want %v", got, want) + } +} + +func TestForkWindowsRejectsMalformedBoundaryTimestamp(t *testing.T) { + _, err := forkWindowsWithLoader(1, time.Now().UTC(), 30, 1, func(int) ([]map[string]any, error) { + return []map[string]any{{"created_at": "not-a-time"}}, nil + }) + if err == nil { + t.Fatal("expected malformed timestamp to fail the snapshot") + } +} + +func TestLastPageParsesGitHubLinkHeader(t *testing.T) { + link := fmt.Sprintf(`; rel="last", ; rel="next"`, 34897) + if got := lastPage(link); got != 34897 { + t.Fatalf("lastPage = %d, want 34897", got) + } +} diff --git a/site/changelog.md b/site/changelog.md index b5b3e6e..6bb6b80 100644 --- a/site/changelog.md +++ b/site/changelog.md @@ -29,7 +29,7 @@ outline: deep ## Unreleased -- **NanoClaw, MicroClaw, and NullClaw drivers retired** ([ADR-026](https://github.com/mostlydev/clawdapus/blob/master/docs/decisions/026-runner-adoption-and-retirement.md), [#353](https://github.com/mostlydev/clawdapus/issues/353)) -- a reproducible upstream-adoption audit (`scripts/runner-adoption-snapshot`, dated evidence under `docs/evidence/`) retired the three runners whose upstream adoption, viability, or corroborating distribution could not justify their maintenance cost. Retired `CLAW_TYPE`s now fail `claw up` with a migration error naming the closest supported runner instead of a generic unknown-driver message. The retained set is `openclaw`, `hermes`, `nanobot`, and `picoclaw`; the rollcall conformance pod and trading-desk example were revised to keep full coverage across all four. +- **NanoClaw, MicroClaw, and NullClaw drivers retired** ([ADR-026](https://github.com/mostlydev/clawdapus/blob/master/docs/decisions/026-runner-adoption-and-retirement.md), [#353](https://github.com/mostlydev/clawdapus/issues/353)) -- a reproducible upstream-adoption audit (`scripts/runner-adoption-snapshot`, dated evidence under `docs/evidence/`) exposed three ambiguous maintenance cases, and the maintainer chose to stop carrying them. Retired `CLAW_TYPE`s now fail `claw up` with a migration error pointing to Hermes instead of a generic unknown-driver message. The retained set is `openclaw`, `hermes`, `nanobot`, and `picoclaw`; the rollcall conformance pod and trading-desk example were revised to keep full coverage across all four. - **Slow scheduled wakes no longer block unrelated targets** -- claw-api dispatches due targets concurrently while serializing wakes per runner, coalesces overlapping slots without regressing next-fire state, rejects duplicate manual fires with a conflict, and drains active scheduler dispatches cleanly on shutdown. Coalesced slots are now recorded in schedule state (`suppressed_slots`, `last_suppressed_at`) and surfaced on the clawdash schedule card, so a schedule whose wake outruns its own cadence no longer reads as perfectly healthy. Closes [#347](https://github.com/mostlydev/clawdapus/issues/347). - **Manual schedule fires honor runner wake budgets** -- `claw api schedule fire` now gives the in-container request 2 minutes 5 seconds and its outer compose transport 2 minutes 10 seconds, enough to return the final result of the longest supported runner wake. Other schedule operations retain their short defaults, and an explicit `--exec-timeout` still overrides the outer transport. Closes [#348](https://github.com/mostlydev/clawdapus/issues/348). - **Hermes scheduled wakes get a runner-sized timeout** -- claw-api now gives `hermes-exec` the same two-minute wake budget as other runner-native cron paths, preventing a successfully triggered Hermes job from being recorded as `exec timed out` solely because the runner took longer than the generic 30-second transport budget. Closes [#345](https://github.com/mostlydev/clawdapus/issues/345). diff --git a/site/guide/anatomy.md b/site/guide/anatomy.md index aeeda04..4eee42e 100644 --- a/site/guide/anatomy.md +++ b/site/guide/anatomy.md @@ -6,7 +6,7 @@ A running Claw splits cognition into two independent layers: **internal executio block-beta columns 1 contract["Behavioral Contract\nread-only bind mount\nAGENTS.md — purpose, on the host\nSurvives full container compromise"] - runner["Runner\nOpenClaw · Hermes · NanoClaw · custom"] + runner["Runner\nOpenClaw · Hermes · Nanobot · PicoClaw"] persona["Persona\nStyle · knowledge · workspace state"] proxy["cllama — governance proxy\nRoutes model calls, injects context, mediates tools\nRunner never sees provider keys"] diff --git a/site/guide/architecture.md b/site/guide/architecture.md index 59c3ab7..6950048 100644 --- a/site/guide/architecture.md +++ b/site/guide/architecture.md @@ -123,7 +123,7 @@ operator policy, not built-in behavior — see [Roadmap](#what-ships-today-vs-ro | Capability | State | |------------|-------| | Four-verb operator surface; `claw up` compiler | Shipped | -| 7 runner drivers (OpenClaw, Hermes, NanoClaw, Nanobot, PicoClaw, MicroClaw, NullClaw) | Shipped → [Drivers](/guide/drivers) | +| 4 runner drivers (OpenClaw, Hermes, Nanobot, PicoClaw) | Shipped → [Drivers](/guide/drivers) | | cllama: credential starvation, compiled tool mediation, normalized telemetry, `claw audit` | Shipped | | Memory plane: session history, portable memory, ambient recall | Shipped | | Context feeds, channel-awareness + channel-memory retrieval | Shipped | diff --git a/site/guide/drivers.md b/site/guide/drivers.md index 12fb18c..8ed4767 100644 --- a/site/guide/drivers.md +++ b/site/guide/drivers.md @@ -22,8 +22,7 @@ New to Hermes? Start with the dedicated [Hermes quickstart](/guide/hermes). ::: info Retired drivers `nanoclaw`, `microclaw`, and `nullclaw` were retired in [ADR-026](https://github.com/mostlydev/clawdapus/blob/master/docs/decisions/026-runner-adoption-and-retirement.md). -Their `CLAW_TYPE`s fail `claw up` with a migration message naming the closest -supported runner. +Their `CLAW_TYPE`s fail `claw up` with a migration message pointing to Hermes. ::: **PicoClaw long-tail platforms:** WhatsApp, Feishu, LINE, QQ, DingTalk, OneBot, WeCom, WeCom App, Pico, MaixCam. diff --git a/site/guide/what-is-clawdapus.md b/site/guide/what-is-clawdapus.md index 0a82fde..6a72489 100644 --- a/site/guide/what-is-clawdapus.md +++ b/site/guide/what-is-clawdapus.md @@ -28,7 +28,7 @@ Any valid Dockerfile is a valid Clawfile. Any valid `docker-compose.yml` is a va ## What It Is NOT -Clawdapus is **not an agent framework**. It does not define how agents reason, plan, or execute code. It supports seven different runner types today -- OpenClaw, Hermes, NanoClaw, Nanobot, PicoClaw, NullClaw, MicroClaw -- and treats them all the same way. +Clawdapus is **not an agent framework**. It does not define how agents reason, plan, or execute code. It supports four runner types today -- OpenClaw, Hermes, Nanobot, and PicoClaw -- and treats them all the same way. Clawdapus is **not a bot-building tool**. It helps you deploy, govern, monitor, and evolve bots that already exist. You bring the agent; Clawdapus brings the infrastructure that makes it safe to run in production. diff --git a/site/index.md b/site/index.md index f1a4cda..d93bd8e 100644 --- a/site/index.md +++ b/site/index.md @@ -28,7 +28,7 @@ features: details: Clawfile extends Dockerfile. claw-pod.yml extends docker-compose.yml. Eject anytime — you still have working OCI images and compose files. - icon: "\U0001F3AD" title: 7 Runner Drivers - details: "OpenClaw, Hermes, NanoClaw, Nanobot, PicoClaw, NullClaw, MicroClaw. Pick your runtime. Same governance layer wraps them all." + details: "OpenClaw, Hermes, Nanobot, PicoClaw. Pick your runtime. Same governance layer wraps them all." - icon: "\U0001F4E1" title: Social Topology details: "HANDLE declares platform identity. Every agent's Discord/Telegram/Slack IDs are broadcast pod-wide. Services can @mention bots without hardcoding." diff --git a/skills/clawdapus/SKILL.md b/skills/clawdapus/SKILL.md index 507b8c4..a52262f 100644 --- a/skills/clawdapus/SKILL.md +++ b/skills/clawdapus/SKILL.md @@ -349,11 +349,11 @@ The policy is tunable from the pod YAML via `x-claw.tool-policy` (service level) ## Communication Tools Contract -All 7 runtimes enforce private thinking + deliberate delivery — agent reasoning never reaches Discord automatically. +All four drivers compile mention-only channel consumption so an unaddressed bot message does not create a feedback loop. - **Hermes**: `HERMES_TOOL_ONLY_MODE=1` injected when Discord handles are present; runtime patches prefer `send_message`, suppress duplicate final text after a successful `send_message`, and fall back to final-text delivery rather than silently dropping replies - **OpenClaw**: enforced natively -- **NullClaw, MicroClaw, NanoClaw, NanoBot, PicoClaw**: `discord-responder.sh` passes a `send_message` tool to the LLM; only posts to Discord when the tool is called +- **NanoBot and PicoClaw**: generated channel configuration enables each runner's mention-only equivalent CLAWDAPUS.md includes a `## Communication Tools` section with private-thinking policy whenever handles are configured. @@ -430,7 +430,6 @@ When the aggregate cap drops a feed the model sees an explicit `--- FEED: | `compose.generated.yml` | Final compose with all enforcement | Next to claw-pod.yml | | `CLAWDAPUS.md` | Per-agent infrastructure map | Mounted into container | | `AGENTS.effective.md` | Merged contract + CLAWDAPUS.md (OpenClaw and Hermes cllama context) | Mounted into container or cllama context | -| `CLAUDE.md` | Combined contract + CLAWDAPUS.md (NanoClaw) | Mounted into container | | `openclaw.json` | Generated runner config (OpenClaw) | Bind-mounted directory | | `config.yaml` / `.env` | Generated runner config (Hermes) | Bind-mounted directory | | `jobs.json` | Cron schedule for INVOKE tasks | Runner state directory | @@ -444,7 +443,7 @@ When the aggregate cap drops a feed the model sees an explicit `--- FEED: |--------|-----------|--------|--------------|-------| | OpenClaw | `openclaw` | OpenClaw | JSON5 Go-native patching -> `openclaw.json` | Primary driver. Read-only container. Docker exec health probe. | | Hermes | `hermes` | Hermes (Python) | `config.yaml` + `.env` | Discord/Telegram/Slack. `HERMES_TOOL_ONLY_MODE`. Requires at least one handle. | -| NanoBot | `nanobot` | Nanobot (Node.js) | `config.json` | Cron via `jobs.json`. Merged AGENTS.md. | +| NanoBot | `nanobot` | Nanobot (Python) | `config.json` | Cron via `jobs.json`. Merged AGENTS.md. | | PicoClaw | `picoclaw` | PicoClaw | `config.json` | HTTP `/health` + `/ready` probe. Read-only container. | Retired (ADR-026): `nanoclaw`, `microclaw`, `nullclaw` — these `CLAW_TYPE`s fail `claw up` with a migration message. diff --git a/testdata/nanobot-stub/AGENTS.md b/testdata/nanobot-stub/AGENTS.md index 9891272..2440a7e 100644 --- a/testdata/nanobot-stub/AGENTS.md +++ b/testdata/nanobot-stub/AGENTS.md @@ -1,3 +1,3 @@ -# MicroClaw Stub Contract +# Nanobot Stub Contract -You are a microclaw stub agent used for clawdapus integration testing. +You are a nanobot stub agent used for Clawdapus integration testing.