Utility Conventions (shared)

Status: Canonical. These rules define how every GodspeedOS utility behaves. They were first written inside 1_observe.md §3 and hoisted here once a second utility existed (per the note in that section - pull the abstraction into existence, do not build a framework speculatively, §26.2).

Each utility has its own numbered doc in this folder (1_observe.md, 2_date.md, …). This file holds only what is common to all of them.


1. The rules

  1. Every utility has help. A bare utility name with no actionable args, or an explicit <util> help, prints usage. With a fresh (non-POSIX) vocabulary, the system must teach its own verbs at the point of use.

  2. Every subcommand has help. <util> <subcommand> help describes that subcommand specifically (e.g. observe now help).

    At every depth, and checked. Below the first word the answer is the block for the word above - wifi debug trace help prints wifi debug's block, which names trace with one line - because a leaf's one line IS its help and a block per leaf would repeat it. Amended 2026-09-29: an audit of every word Tab could reach found 22 first-level words with no help at all (chaos kill-storm help read help as a service name and refused it with the list) and nothing at depth three. The answers live in one place, sub_help in services/shell/src/main.rs - a command with its own per-word help (events, trace, chaos) is reached from there by a delegating arm, so <util> <word> help has exactly one answer whichever way the words arrive. scripts/subcmd_help_check.py reads SUBCMD_FIRST and sub_help and fails on any word Tab offers that answers nothing; it runs on every build.

  3. help is the word - the only form. No flags, no synonyms. There is exactly one way to ask for help: the word help. No -h, no --help, no hidden aliases. A tolerated-but-undocumented synonym would itself be a hidden, unsaid rule - the silent behaviour the system forbids (§26.4, §26.5). -h is simply unknown:, and that response teaches the real word.

    help <word> and docs <word> are not an exception to this, and the distinction is worth stating because it looks like one. <util> help gives one command's detail and remains the only way to ask for that. help dir does something else: it opens the browsable list already scrolled to dir, which is what man actually means - find this in the manual. It exists because the browser LISTS the commands, so reading dir there and typing help dir is an expectation this interface creates; and because the alternative was worse than either reading, since the argument used to be discarded in silence. A word that matches nothing says (no match) rather than sitting at the top looking like a hit.

  4. Subcommands are words, never single-letter flags. observe now, not observe -n. A word means the same thing across every utility; flag letters collide and drift (-n = "now" here, "number" there). This is the ls -Sslah wall-of-letters problem GodspeedOS rejects. Typing economy is a shell-ergonomics concern (completion, history - future), not a reason to abbreviate vocabulary.

  5. Every utility has a version, reported by <util> version. Per utility, not per subcommand - subcommands evolve with their parent and inherit its version.

  6. help output carries the version header. Line 1 of any help/usage output is <util> <version>, so the version is always one keystroke away.

  7. Utilities report raw facts; they do not editorialize. Health verdicts, policy, and recommendations belong to purpose-built utilities (e.g. ask, 60_ask.md), not bolted onto a metrics or info command.

  8. Vocabulary is not POSIX. GodspeedOS has no fork/exec/errno/signals, so the user-facing words do not borrow POSIX names that would imply that heritage. Concretely: the clock's epoch subcommand is date epoch ("seconds since 1970"), not date unix; there is no time clock (in Unix time measures command duration). Pick words that say what the thing is in this system.

  9. Every utility and subcommand tab-completes. The name completes from the command list; each subcommand keyword completes at its position (net a -> net arp, net ver -> net version, ping c -> count). Rule 4 makes words the vocabulary; completion is what makes typing words as cheap as flags. A subcommand that does not complete is one the user cannot discover without reading the source. Wire it in the shell's completion tables (SUBCMD_FIRST / the per-command case) the same commit that adds the subcommand.

    Declare whether your arguments are file paths (same commit). When a token is not a recognized keyword, Tab falls through to file-path completion - it lists the current directory (dir /x<tab>, read /doc<tab>). That is correct only for utilities whose arguments are paths. A utility whose arguments are service names, numbers, or fixed keywords - never paths (chaos, kill, spawn, restart, ping, net, drives, observe, date, uptime, ...) must be added to NO_PATH_CMDS in the shell (services/shell/src/main.rs, beside complete_tab), so Tab at one of its argument positions does nothing instead of offering unrelated files. (The bug this prevents: chaos max-carnage all-services <tab>, landing on the rounds argument, listed the root directory and offered /.gsh_history, where the history then lived.) The default is path completion; opting out is explicit and per-command - a path-taking utility (dir, read, write, mkdir, find, tree, copy, ...) is simply absent from NO_PATH_CMDS and keeps its file completion. So when you add a utility: if its args are paths, do nothing; if they are not, add it to NO_PATH_CMDS.

    version and help complete for free - do not list them. Every utility answers <util> version and <util> help (rules 5-6), so complete_keyword appends them to every command's first-argument completion automatically. Do not add version/help to a SUBCMD_FIRST entry (they are deduped in). A utility that takes no positional first argument (an info command: about, version, mem, uptime, status, ...) has the universal version/help as its only first-arg subcommands - add it to INFO_CMDS (beside NO_PATH_CMDS) so those two complete and Tab never falls through to a filesystem listing. So a new utility is exactly one of: a path command (in neither list), a keyword command with specific subcommands (SUBCMD_FIRST + NO_PATH_CMDS), or an info command (INFO_CMDS). Pick one; version/help come along in every case. 10a. A bare letter is a control ONLY where letters are not text. Full-screen surfaces divide cleanly in two, and the apparent inconsistency between them is forced rather than an oversight:

    • Read-only views - help, docs, paginate, the console's scrollback - take q and Esc to leave, because nothing there is typing and a letter is free to mean something.
    • Typing surfaces - edit - cannot. A bare q must insert the letter q, so leaving and saving need a modifier: ^Q and ^S.

    NAVIGATION IS THE SAME EVERYWHERE, and that is the part that must not drift: arrows move a line, PgUp/PgDn a page, Home/End the ends.

    Name the two keys that work, not the cluster. A status line reading [arrows] claims all four when only up and down do anything, and left/right are free to mean something else later. It reads [up/down].

    In WORDS, because the console cannot draw the symbols. render::cell_for_codepoint passes ASCII through and maps eleven box-drawing characters; everything else becomes a literal ? (deliberately - visible, never silently dropped). So \u2191 and \u2193 would print as ?? on a framebuffer while rendering correctly over serial, which is worse than either. Words also match rule 4: a word means the same thing everywhere, and symbols are what that rule exists to avoid. edit already matches help, docs, paginate and the scrollback view key for key on all five. Somebody "fixing" edit to quit on q would break the only rule that actually distinguishes them, so the rule is written down rather than left to be inferred from two examples that look contradictory.

  10. Nothing traps the user: every wait says [q] quit, by 4 s, and honours it. (Rewritten 2026-10-13 with the operator, backlog/fixed/89, audits/2026-10-13-quit-audit.md.) Any command a person waits on that can run past 2 s says [q] quit - aimed at 2 s, and never later than 4 s on the slowest board, because the shell's clock counts whole seconds and a reply may already be on its way - and q/Q/ESC brings the prompt back within 3 s, saying truthfully what happened: stopped, still running underneath, or outcome unknown. [b] background stands beside it where the work can run detached - a separate program, holding no console authority, whose output fits a transcript - and a command with no [b] says nothing about [b]. So copy <file> <dst>, `delete

    recursive`, `drives check` and `drives scrub`, typed at the prompt, START as the job `background` would start and the console attaches to it: their notice is `[q] quit [b] background`, and `b` leaves them running as job N (`55_background.md`). In a script, a pipe, or while a job is already running they run in the shell and offer `[q]` alone. Waits on the person (a confirm, `input`, a full-screen program with its own quit key) are exempt.

    The shell is the observer. It starts a COMMAND clock at Enter, and every wait the command makes reads it through gs::call::Watch - a gs::fs handle lent .watching(ctx) (the shell's ctx.fs()), gs::call::request_watched, recv_quittable - so the notice is per command, a command made of many fast requests is covered as well as one slow request, and q stops the command at its next request. A script, a wait and selfcheck stop on it too. (tcp, serve and sock use a gs::net handle with its own notice: quittable, on the request's clock rather than the command's.) A wait the watch cannot see is a defect to fix; the few that cannot trap anyone - under 2 s, one pass of a loop that reads q, after a q, or between commands - say so beside them (// quit-ok:), and scripts/quit_wait_check.py refuses any other. osdev test quit measures every kind of wait on an fs built to answer slowly (slow-replies) and writes the table to build/tests/quit_report.md.

    The rule as it read before 2026-10-13, kept as history: Anything that blocks or waits is escapable with q. If a utility can sit waiting - on a peer service, on the network, on a long sweep - then q/Q/ESC MUST abort it and return to the prompt, and a wait of more than a moment advertises [q] quit. A command that can wedge the shell with no way out is forbidden (§26.7: loud + escapable over silent + stuck). The primitive is ServiceContext::request_with_reply_abortable (send once, poll q while waiting); never block an interactive command on a bare request_with_reply to a peer that can be slow. The fs-backed interactive commands (dir/cd/read/find/tree and the file-reading filters match/count/sort) use the delayed-hint variant: a gs::fs::Fs handle lent a notice (Fs::with_notice / Fs::noticing), which waits through gs::call::request_within_notice. Silent on a fast reply, it advertises [q] quit only once the wait lingers past gs::call::NOTICE_AFTER_SECS (2 s) - the just-in-time form of the advertisement above, so a snappy op stays quiet.

    (Note 2026-10-09: of that list, only dir's text listing lends the notice today. cd, read, find, tree and the file-reading filters go through gs::fs::Fs handles built with no notice (fs_stat_r, fs_read_at, filter_read, the walks in cmd_find/cmd_tree), so they are bounded at gs::call::DEFAULT_SECS but cannot be ended with q - a code defect against this rule, recorded in each utility's own spec.)

    (Note 2026-10-12: the advertisement is now AUTOMATIC on the shell's receive waits, at the operator's standing rule - "for every request that takes time, there should automatically be a [q] to quit". A pipe stage's drain (drain_service_with, which ask also goes through) and a file-cap reply wait through recv_quittable in services/shell, which prints [q] quit once nothing has come for gs::call::NOTICE_AFTER_SECS, once per wait. The SDK gives the timing (ServiceContext::recv_abortable_deadline_noticing, the receive-side twin of the request's request_with_reply_qhint) and the shell the words, because a task with no prompt has nothing to print. A new receive wait in the shell gets it by going through that helper. Since 2026-10-13 the request waits that used plain request_with_reply_abortable (fs_request, fs_raw - drives, drives flash, reset, label - and ns_abortable) go through ask_with_quit_notice, and the re-waits after an overtaken reply (fs_take_tagged, ns_take_tagged) through recv_quittable (backlog/84 S6). Not yet covered: the fs handles listed in the note above - covered since 2026-10-13, backlog/fixed/89.)

    *(Note 2026-10-13: and the radio's and audio driver's asks. wifi_ask and audio_ask - every wifi and audio verb, wifi hardware among them, which asks each radio twice and could sit for 12 s silent - wait through ask_sifted_quittable (the SDK's request_with_reply_keyhint_sifted_keeping), and wifi_ask's wait for answers the driver still owes says it too; q there prints stopped by q rather than "not answering". observe now's wait for its frame and date sync say [q] quit as well. Still silent, and why: the waits on a kernel Call

    • recorder and copier (8 to 12 s), time and the ns_deadline path to net-stack (each at its caller's deadline), and the 2 s spawn request to the supervisor. A task blocked in a Call cannot read the console, and the noticing form that can takes whatever reply lands next, which is what the Call is there to prevent: giving them [q] needs a reply-matched wait that returns to the caller between slices, which does not exist yet. Fixed later the same day: all four are under the command's watch (ask_watched, hw_ask), backlog/fixed/89.)*

    Worked example, and the reason "never block on a bare request_with_reply" is written as an absolute. The whole networking surface broke this rule on one helper. ns_request - what tcp, sock and serve's listen all go through - used a bare request_with_reply, which parks the shell INSIDE the syscall where it cannot poll the console. On a Dell Wyse a tcp that net-stack never answered froze the prompt: no echo, no q, and the machine had to be power-cycled. A deadline was not the fix and adding one first was not enough - it made the freeze 20 seconds instead of forever, and 20 seconds of a dead prompt is still a dead prompt. Only polling q while waiting returns the machine to its operator. The events channel (trace_ask) had the same shape and was worse for it, since events failures is the instrument you reach for WHEN something is wedged; an instrument that can hang on the thing it is measuring takes the prompt with it.

    (Superseded 2026-10-13, backlog/fixed/89: these requests now go through ask_sifted_quittable, say [q] quit past 2 s and stop the wait on q. Kept as the record of why they once could not.) Recorded exception: the wifi radio power requests. wifi radio on, wifi radio off, wifi radio off hard and the power request at the start of wifi radio powercycle block in a kernel Call with no q, because a shell blocked in one cannot read the console. Each is BOUNDED at 15 s (MAX_SECS in wifi_radio and wifi_radio_hard_off, OP_MAX_MS in wifi_radio_powercycle), and the usual wait is two to three seconds. The long waits that follow

    • the watch after a restart of the driver - keep q. 56_wifi.md section 2 records this per verb.
  11. Quitting stops the TASK, not just the shell. When a utility is escaped (rule 10), the escape must abort the actual WORK the utility set in motion - not merely stop the shell from waiting on it. If the utility handed a long job to a peer service and the escape only stops the shell listening, the peer keeps grinding and the very next command blocks behind it: the shell looks free but is not. So a command that drives a multi-step peer operation (a sweep, a batch) drives it step-by-step from the shell, so q ends the loop and the peer is only ever mid-ONE step. Worked example: net scan began as one batch net-stack op (sweep all 254 hosts); pressing q unblocked the shell, but net-stack kept sweeping, so a second net scan hung waiting for it. The fix made the shell drive the sweep one host at a time (op 7 -> per-host op 6). Escaping the wait is not enough; escape the work.

  12. Output is a pipeable structure. A utility's result is data, not decoration: a producer emits either a typed record Table (docs/records.md, so | where / | select / | to json compose) or plain labelled lines (so | match / | count compose). Piping is the composition model; output that cannot flow onward is a dead end.

    Which utilities start a pipe, and why the rest do not (counted 2026-10-02 against the shell's UTILS and its three producer lists, is_producer_builtin, is_record_producer and is_pipe_producer_service). Of the shell's utilities (its UTILS list), 28 start a pipe (two, wifi and churn, for some verbs only): the record sources (status, dir, find, caps, drives, observe now, uptime, events, trace, jobs, and wifi list), the text sources (echo, read, tree, input, about, version, whatis, mem, cores, date, net, ping, sock, help, pwd (added 2026-10-13), wifi's other reports, churn verify), and the roster service; plus random and tcp (made sources that day) and the greet example, which are not in UTILS. Seventeen more are pipe STAGES or SINKS rather than sources - match, count, sort, first, last, where, select, to, from, sum, min, max, avg, paginate, write, assert, result - and result/assert refuse to start a pipe with a sentence that names the idiom they belong to. The other 22, each for a reason rather than an omission (the library scripts, serve and gpio are not in UTILS and are listed with them):

    Not a pipe sourceWhy
    run, selfcheckOrchestrators: they run their own sub-pipelines, so capturing one nests a pipe inside a pipe on a 256 KiB stack - a hardware-proven crash. They write their own output with save <path> instead
    health, online, size, busiest (the library)Scripts, run by the interpreter: the same class as run, refused inside another script for the same stack reason. Their contents pipe directly - busiest IS status | sort reverse mem
    docs, scrollback, edit, bare observeFull-screen: a pipe cannot carry a screen you scroll. scrollback save and observe now are the capturable forms
    clear, wait, repeat, cd, home, mkdir, copy, move, rename, delete, seal, spawn, kill, restart, reboot, background, foregroundActions: the value is the effect, and what they print is a confirmation, not data
    wifi scan, join, leave, forget, radio ...Actions; join also reads a passphrase from the console, and piped its prompt would vanish. Refused with a sentence naming the reports
    churn <seconds>, churn tear, churn resetActions; churn verify is the report and pipes
    chaos, fcapExercisers whose value is what they prove while running; chaos writes its report with save
    serve, gpioserve runs until stopped; gpio drives pins - the one read it offers is a single line on the Pi 2 only

    (Note 2026-10-09: the count above is dated. audio and hardware have since joined UTILS and both are in is_producer_builtin, so UTILS now holds 68 names and 29 of them start a pipe; the 17 stages and the 22 non-sources are unchanged.)

    (Note 2026-10-25: recounted. UTILS holds 82 names and 34 of them start a pipe - ask, godspeed (its reports only), audio (its reports only) and hardware joined, and random and tcp are in UTILS now, as are serve, gpio, spawncap and spawnwired. The 17 stages are unchanged; the other 31 are not sources, shutdown and download among the actions.)

  13. If it does not fit the common pipes, write still captures it. Any producer's output snapshots to a file with | write <path> (redirection is | write; there is no >, see 19_write.md). So even a utility that is not a record source is never trapped on screen - its bytes always have somewhere to go.

  14. Multiple same-type targets are a COMMA-separated list, never spaced. kill ehci,xhci,fs, spawn ping,pong, restart fs,events, delete /a,/b, mkdir docs,tmp, fmt a.gsh,b.gsh, chaos max-carnage nic-driver,net-stack - one argument, comma-delimited. NOT spaced (kill ehci xhci fs): the shell tokenizes a line to a small fixed arg count (MAX_ARGS = 8), so a spaced list silently caps at ~3 targets, while a comma-list is a SINGLE token and is not capped by it (a list takes at most 16 items, LIST_MAX; a longer one is refused before anything runs). Comma is also the one uniform rule - the same separator on every command - so the user never has to guess which verb wants which shape. Each target runs the command's normal single-target path (same guards, same per-target report); a failure on one does not abort the rest; the set is bounded. A single target with no comma behaves exactly as before. The keyword all-services (kill only) is the whole system set expressed as one target.

Help output shape (normative)

Every usage row carries both a placeholder signature and a real example - the placeholder teaches the grammar, the example teaches what real input looks like (not everyone knows what <path> should be filled with):

<util> <version> - <one-line description>

usage:
  <util>                        <default behaviour>
      e.g. <real example>
  <util> <subcmd>               <what the subcommand does>
      e.g. <real example>
  <util> version
  <util> help

<util> <subcmd> help prints the same shape for that subcommand, without the version / help footer (help_block in services/shell/src/main.rs).

version output is the name + version number, then the creator credit:

<util> <version>
Copyright (C) 2026 Bankole Ogundero and the GodspeedOS contributors.

2. Built-in vs standalone-service utilities

Two implementation shapes exist, and each utility's doc states which it is:

  • Shell built-in. Runs inside the shell's own protection domain, using caps the shell already holds. Cheap and simple; appropriate for trivial output commands (echo, clear) and read-only info that the shell is already authorised for (mem, cores, about, date, status, caps). The shell's authority is not widened for these - they only read or print.

  • Standalone service brokered by the shell. A separate task the shell spawns, holding a contract-scoped least-authority cap set and nothing more. Used when the utility must not run alongside dangerous authority. observe is the canonical example: it needs only to read metrics, so it is its own service holding an introspection cap - it cannot kill or restart anything by construction, not by being careful (§3.1, §26.9). See 1_observe.md §7.

The dividing question is least authority: if a command would otherwise execute in the same domain as spawn/kill/restart, prefer a standalone service.


2a. Adding a utility: the eight sites, and where its spec lives

Written down because it was learned by failing. Implementing one verb (wifi, 2026-09-27) needed eight registrations in services/shell/src/main.rs, and the two that were missed were reported by two different checkers rather than by any list - which works, but late. The knowledge lived only in the checkers that enforce it.

SiteWhat it gives youWhat fails if you miss it
SUBCMD_FIRSTtab completion of the first argumentrule 9; Tab falls through to a directory listing
SUBCMD_SECONDcompletion one level deeper, where the surface has onerule 9, silently
NO_PATH_CMDSTab offers keywords, not paths, where no argument is a patha path menu for a keyword position
UTILSthe <util> help / <util> version interceptrules 1 and 5
the command dispatchthe verb runs at allnothing runs
the producer dispatch<util> | write <path> captures itrules 12 and 13
a help_block arm<util> help prints somethingcaught by util_help_coverage_problems
a Row in the help listinganybody can DISCOVER the verbcaught by facts_check - "an omission ships the feature to nobody who was not watching it being built"

And where the spec goes, which is not a preference. A spec under utilities/ asserts that the shell answers that verb, and X-user-vocabulary (Commandment X) enforces it in both directions:

  • A spec here for a verb the shell does not answer fails. Implement it, delete it, or document the ABSENCE (14_poweroff.md is the worked example, and the head must say "not provided" or "removed").
  • A verb the shell DOES answer with no spec here also fails - a verb discoverable only by reading the source is complexity pushed onto the user (CLAUDE.md 26.11).

So a surface designed before its verb exists belongs in docs/ and moves here the day the shell answers it. docs/tcp-design.md against 48_tcp.md is that split; docs/wifi.md against 56_wifi.md is the same, and the file moved three times before anyone wrote this paragraph.

Do not reach for utility_vocab_debt to make either direction quiet. A baseline entry instead of a fix is what CONTRIBUTING.md names as weakening a gate.


3. Conformance status (as-built, honest)

As of 2026-06-14, every utility conforms. Each one implements its own <util> help (usage with a real example per row) and <util> version (number + "Copyright (C) 2026 Bankole Ogundero and the GodspeedOS contributors."), and each real subcommand (date epoch, observe now, drives flash / label / reset) has its own <util> <subcmd> help.

The shell built-ins are driven by a single help_block helper (the format lives in one place, so all of them render identically), with <util> help / <util> version intercepted uniformly in the command dispatch; observe (a standalone service) was already spec-first conformant. The earlier gap - built-ins documented only by the top-level help list - is closed.

The last-open item is now closed too: the top-level help command conforms. Bare help is still the categorised command list, but its first line now carries the version header (rule 6: help 0.4.0 - GodspeedOS shell commands), and help help / help version resolve like any other utility's. So every command the shell dispatches - including help itself - self-documents and reports a version.