Command nodes in the map — B-019(б)
01Scope. BACKLOG.md ##B019-B asks that a
command be an entity of the map rather than only a function, so that «what
implements vibe install» is answerable directly. The owner's ruling of
2026-08-01 is to build it and to build it algorithmically, without an LLM.
This design covers that part and nothing else: part (а), the code fingerprint,
is built and live on 916 of 932 items; part (в), the error-variant node, carries
an unresolved systems-boundary question the owner asked to be answered before
implementation, and it is not in here.
02It does not ride the format change.
map-format-change.xml ##non-goal-command-nodes already
ruled that (б) is a separate node type with its own extraction, not blocked by
that change and not carried by it. This document is the sibling that ruling
points at.
1. What was measured at design time
03Every reading in this section is dated 2026-08-06,
taken BEFORE slice 1 landed. They are kept as they were because they are the
basis the design's choices were made on, and rewriting them would erase the
reasoning while keeping the conclusion. Four have since moved, all by the build
this document commissioned rather than by regression — see
##m-re-measured-after-slice-1. Read this section as evidence for a decision,
not as a description of the tree.
04Re-measured 2026-08-06 after slice 1, and the
four movements. (i) The map carries 997 code items in nine kinds, not
932 in eight — command is now one of them with 57 nodes, which is the whole
point of the build. (ii) The scanner can read #[derive(…)]; that was
slice 1's one added capability. (iii) rscan.rs is 571 lines, not 511 —
still inside the budget, and the submodule the budget argued for exists as
cscan.rs, so the prediction held and the number simply grew. (iv) The command
count is 57, not the 56 slice 1 measured: vibe carries 30, because
vibe tools shipped later the same day. Growth, not regression — the same
correction shape ##B019-A-COUNT-MOVED records for part (а).
05One node proves
##n-variant-name-rule rather than asserting it. The map carries vibe command,
and no Command variant exists: it is Drain, carrying
#[command(name = "command")] at crates/vibe-cli/src/cli.rs:161. The extractor
recorded the string the user types over the Rust identifier, which is exactly what
that rule demands and the only place in the map where the two differ.
06The committed map carries 932 code items in eight kinds —
mod 415, fn 376, enum 62, struct 52, schema-def 9, schema 7, impl 7,
trait 4. No kind contains the substring command. Reproduce by tallying
code_items[].item_kind in specmap.json.
07item_kind is an open string, and this is the
load-bearing measurement. schemas/specmap.jtd.json:92-94 declares
"item_kind": { "type": "string" } with no enum, while its neighbours verb,
spec_unit.kind, status and provenance all carry one; the Rust model
(generated/specmap/mod.rs:44-45) types it String. So a new kind is a new
value of an open field, not a schema bump — the opposite of B-019(а), which
map-format-change.xml:72 correctly called a real bump because it added net-new
fields.
08No production code matches or filters on
item_kind. explain.rs:158 prints it through, explain.rs:243 and
vibe-trace/src/fragment.rs:486 pass it through into JSON, and the only
equality tests are two in jtd/tests.rs that look for specific values rather
than assert a closed set. There is no kind→display-name table for code items.
A new value therefore breaks nothing downstream.
09The Rust scanner cannot see #[derive(...)]
today. rscan.rs:89-134 edges_from_attrs is the only reader of attributes;
its match on the attribute path's last segment has exactly two non-wildcard
arms — "spec" (:100) and "verifies" (:115) — and _ => {} (:130)
swallows everything else. grep -ni derive rscan.rs returns nothing.
10explain's target grammar is open, and this is the
second load-bearing measurement. explain.rs:199-204 branches on one string
prefix — spec:// goes to explain_unit, everything else to explain_symbol —
and explain_symbol (:123-150) matches codeItems[].symbol exactly, then by
suffix, without ever consulting item_kind. There is no closed enum of
target kinds to extend.
11All three language stacks declare their commands
identically, because all three CLIs are Rust crates on clap —
rust-ai-native-cli/src/main.rs:27, typescript-ai-native-cli/src/main.rs:23,
go-ai-native-cli/src/main.rs:23. What is per-language is the code extractor
each drives (syn in-process for Rust; the ts-extract and go-extract
sidecars for the other two) — not the command declaration.
12The engine crate exists in 21 directories: one authored
and 20 copies — every surviving package and materialized slot is at 1.0.0.
The authored one is
vibevm/vibepacks/org.vibevm.ai-native/core-ai-native/v1.0.0/crates/core-ai-native-specmap/.
Every engine edit is followed by cargo xtask sync-engines as its own step.
2. How a command is recognised
13Three answers exist. (A) Recognise the framework: an enum
carrying a Subcommand derive is the command enum and each variant is a command.
(B) Make the author mark it — a new attribute or a specmark unit.
(C) Name the enum in specmap.toml.
14(A), and the reason is a law rather than a preference. (B) and (C)
both fail ##WAL-C-A-NORM-WITHOUT-A-CHECKER-DRIFTS: a subcommand added without
its marker, or without its config line, is simply absent from the map and nothing
says so — the shape this repository paid for on the licence norm, where one crate
of twenty fell out of a rule for months. (A) cannot drift, because the same
declaration that makes the command exist for the user is the one the scanner
reads.
15The objection that (A) puts a framework into a
language-neutral engine does not hold, because it puts it into
rscan.rs — the Rust-specific scanner, which already knows syn. Knowing clap
is one more Rust-ecosystem fact in the layer where Rust-ecosystem facts belong;
the neutral core keeps knowing only that a code item has a kind. Per
##m-all-three-stacks-are-clap this single Rust reader already covers the host
and all three stack CLIs. A consumer project written in Go or TypeScript would
declare its commands in its own ecosystem's idiom, and its extractor belongs in
that language's existing sidecar — ##WAL-C-PARITY-IS-THE-INVARIANT-NOT-THE-CODE:
parity is that a command is a node of the map, never that the code is the same.
16Both derive spellings must be recognised. This tree
carries #[derive(Debug, Subcommand)] (crates/vibe-cli/src/cli.rs:94) and
#[derive(clap::Subcommand, Debug)] (:267). A reader matching one form finds
part of the surface and reports a clean number — the failure mode
##WAL-C-A-GREP-LIES-IN-BOTH-DIRECTIONS names. The match is on the derive
path's last segment, exactly as edges_from_attrs already matches attribute
paths.
17This is engineering judgement, not the owner's court. B-019's owner fork is (в)'s systems boundary and it is a different question.
3. What a command node carries
18A code_item with item_kind = "command" and the fields that
already exist. Nothing is added to the wire format.
| field | value |
|---|---|
symbol |
the invocation path — vibe install, vibe registry redirect |
item_kind |
"command" — a new value of an open field (##m-kind-is-an-open-string) |
crate_name |
the crate declaring the enum |
file / line / end_line |
the variant's span, same attribute-inclusive convention as every other item |
fingerprint |
the variant's token stream, tok1:<sha256> — so «the command's declaration changed, re-check what it links to» works, which is (а)'s purpose applied to a new node |
20The symbol is the invocation path and not the
Rust path, and that choice is what makes the row's question answerable. With
symbol = "vibe install", vibe explain "vibe install" resolves through the
existing explain_symbol path with no change to explain at all
(##m-explain-target-is-open). A Rust-path symbol would answer the same question
only after a translation step nobody asked for.
21The binary half of the path is read from the
#[derive(Parser)] root's #[command(name = "…")] — declared in this tree at
crates/vibe-cli/src/cli.rs:47. Where a root declares no name, clap's own
fallback applies and the extractor uses the same source clap does rather than
inventing one.
22The variant half is clap's own rename rule
(Install → install, RedirectSync → redirect-sync), and an explicit
#[command(name = "…")] on a variant wins over the derived form. The map's
string must be the string the user types, or the node answers a question nobody
asked.
4. Where the extraction lives
23A command variant is reachable from the walk that
already runs. walk_items visits syn::Item::Enum(e) at rscan.rs:173, and
e.variants hangs off that item; descending into variants is the same shape as
the descent into trait methods (:182-188) and impl methods (:211-217) that
the walker already performs. No second traversal of the tree is required.
(The M-B019B measurement concluded that a command «cannot be fitted into
rscan.rs's match» and needs a pass parallel to jtd.rs. The fact behind that
— a command is not a top-level syn::Item — is true; the conclusion does not
follow, because the enum that declares it is.)
24It cannot ride tag_item, and that is the one real
obstacle. rscan.rs:144-147 returns early when an item carries no
#[spec]/#[verifies] edge, so today an item is recorded only if it is
tagged. A command exists whether or not anyone tagged it, so command nodes go
through record_item (rscan.rs:46), the unconditional recording path, which is
already there.
25The binary name and the nesting are a crate-wide
join, and that is the design's only structural cost. The Parser root lives in
one file (cli.rs), the group enums in others (cli/registry.rs,
cli/progress.rs, …), and scan_source (rscan.rs:254) is per-file. Three
relations must be collected during the walk and joined after it: (i) root
struct → binary name and the type of its #[command(subcommand)] field;
(ii) enum with a Subcommand derive → its variants and each variant's payload
type; (iii) args struct → the type of its own #[command(subcommand)] field,
where it has one. scan_workspace (:313) already accumulates across files, so
the join is a post-pass over state it already holds.
26rscan.rs is 511 lines against the
600-line budget, so this lands as a submodule the scanner calls — and the budget
is the reason for the file, never for the design.
##WAL-C-FILE-BUDGET-DOES-NOT-CHOOSE-A-TYPE: a length budget may decide where
code sits and may not decide what it is. Any new file carries
specmark::scope!(…) in its crate's own form, or the panel's self-trace reports
its helpers as orphans.
5. The landing cut
27Slice 1 — top-level commands, every binary the tree declares. The
derive reader, the unconditional record path, the crate-local root-to-enum join,
symbol as <binary> <command>. Acceptance is a number, and it is measured:
the host's map carries 56 command nodes — vibe 29, vibe-index 14,
xtask 13.
28That number was wrong three times
before it was right, and each correction widened the perimeter of the
measurement rather than fixing a regex. It is worth the paragraph, because the
acceptance number is the only thing that caught the one real defect.
(i) 29 — this design's first figure, taken from the surfaces census
(g6-b047-surfaces-census.md).
The census is right and says something else: it counts vibe's top-level
command surface. Taking it as a node count was the boss's error.
(ii) 43 — the build's own figure, measured over crates/, adding
vibe-index's 14 (vibe-index/src/cli/mod.rs:47, name = "vibe-index").
Right about the second binary, still scoped to one directory.
(iii) 71, of which 29 were false — what the regenerated map actually
carried. Both vibe-cli and vibe-index declare pub enum Command, and the
join matched a root to an enum by type name alone across the whole workspace, so
find handed both roots the same enum: the map claimed vibe-index agentic and
vibe-index term, which do not exist. The join is therefore crate-local — the
pair (crate_name, type_name) — and a test pins two roots in different crates
whose enums share a name (proved to fail without the fix: exit 101, the
assertion showing one binary carrying the other's commands).
(iv) 56 — the truth, and it includes a third binary neither earlier
perimeter contained: xtask, which is not under crates/ at all.
29One reading in between was 0, and it
was not the code. After the crate-local fix landed, the regenerated map carried
no command nodes and no command-* warnings — the signature of an extractor that
never ran. Both the authored crate and the vendored copy the host links were
verified correct by reading. cargo clean -p core-ai-native-specmap and one
re-run produced 56. This is the standing trap recorded at
#fact-engine-enum-ripple: a stale fingerprint in the host target builds fixed
sources against a pre-change rmeta. A zero from the map after an engine edit is
a build question before it is a code question.
30Slice 2 — nesting. A variant whose payload type carries its own
#[command(subcommand)] yields commands one level deeper, with the parent's path
as their prefix. Its acceptance number is NOT yet known and must not be taken
from the census. The census's 68 is vibe's subcommand total; slice 1's
history (#cut-1-the-number-was-wrong-three-times) is exactly the demonstration
that a host-only figure is not a map figure — vibe-index and xtask have
nested commands of their own, and nobody has counted them. The slice measures
first and states the number afterwards.
31Slice 3 — the acceptance, which is expected to need no code.
vibe explain "vibe install" must answer, and per ##m-explain-target-is-open
the path is already open. The slice is a test that would have failed before
slice 1, plus the map regeneration. If it turns out that explain does need a
change, that is a measurement contradicting ##m-explain-target-is-open and it
is reported as such rather than absorbed.
32Every slice that edits the engine ends with
cargo xtask sync-engines as its own step (##m-twentyone-copies), and a slice
that adds a .rs file to an engine crate ends with cargo xtask specmap in the
same landing — a new scope! unit moves the committed map.
6. Non-goals
33Not part (в). The error-variant node's systems boundary — whether
specmap extracts the data itself, reads conform's, or the two are joined only
at query time — is the owner's to answer before implementation, by his own
requirement recorded in ##B019-V. Nothing here presumes an answer.
34Not a schema bump. If a build measures that
item_kind is closed somewhere this design did not find, that is a refusal to be
reported with the line, not a redesign to be improvised.
35Not the spec-side half of (а). Revision marks on the ~80 spec sections that error messages cite are a separate lifetime and are untouched here.
36Not command extraction for consumer projects in Go or
TypeScript. Their CLIs would declare commands in their own idiom and their
extractors belong in the sidecars those languages already ship. This design
covers the Rust+clap surface, which per ##m-all-three-stacks-are-clap is the
host and all three stack CLIs.