substrate
A registry for multi-repo workspaces that makes reuse checkable instead of
remembered. Each repo declares its reusable primitives — engines, APIs,
pipelines, toolkits, agents, patterns — in a substrate.json manifest;
substrate-graph.mjs compiles every manifest into one generated
SUBSTRATE.md graph of nodes, consumes-edges, orphans (dormant value) and
hubs (harden first); and a SessionStart hook injects the compact summary
plus staleness alarms into every session, including post-compaction, so the
sweep-before-build doctrine has something deterministic to sweep.
Layout
scripts/lifecycle.mjs— zero-dependency validators the checker leans on: strict ISO-8601 timestamp parsing, duplicate-JSON-key detection, and per-entry deliverable indexing.scripts/substrate-graph.mjs— the whole engine: zero dependencies, Node ≥ 18, cross-platform.--emitwritesSUBSTRATE.mdand exits 1 on manifest problems (the CI half);--checkprints the summary and always exits 0 (the session half);--jsonprints the same graph as machine-readable JSON — deterministic, sanitized, exit 1 on manifest problems — so other tools (fluxpoint's recall layer among them) consume a projection instead of parsing prose or raw manifests. Root resolution:--root <dir>, elseCLAUDE_PROJECT_DIR, else the current directory.hooks/hooks.json— one hook set for Claude Code and Codex.SessionStartwith no matcher, so startup, resume, clear, and post-compaction all get the--checkoutput; the output is factual statements only, and a stale manifest is reported, never commanded. A secondSessionStarthook runsmemory-lint.mjsover the workspace's memory files, and aPostToolUsehook on file writes runs the prose-smell gate (both below). APreToolUsehook onBash|PowerShell|Monitorruns the opt-in mail-sender check described below.scripts/send-chokepoint.mjs— detects recognized mail-send paths outside the repository's declared sender. Dormant without.send-chokepoint.json.commands/—/substrate:init(onboard a workspace),/substrate:status(run and interpret--check),/substrate:emit(regenerate, and keep the manifest edit in the same commit as the primitive it describes),/substrate:smell(scan prose before it ships). On Codex each is reached through the matchingskills/substrate-<name>/skill.templates/—DOCTRINE.snippet.md, the sweep doctrine, ready to append to a workspaceCLAUDE.mdorAGENTS.md;WRITING.snippet.md, the outward-prose rules a regex cannot check.tests/—node:testsuites over temp-dir fixture workspaces; wired into this repo'sscripts/harness.sh --full.
Install
Claude Code:
/plugin marketplace add Flux-Point-Studios/fluxpoint-plugins
/plugin install substrate@fluxpoint
Codex:
codex plugin marketplace add Flux-Point-Studios/fluxpoint-plugins
codex plugin add substrate@fluxpoint
Onboard with /substrate:init <repo>
(Claude Code) or $substrate-init <repo> (Codex) in the workspace that
contains your repos.
Manifest schema
<workspace-root>/<repo>/substrate.json, discovered exactly one level under
the root:
| Key | Type | Meaning |
|---|---|---|
repo | string | Display and sort name for the repo (usually the dirname). |
version | number, optional | Schema version; 1 is tolerated and currently the only value. |
primitives | array | The repo's reusable primitives. Empty is valid — the repo still appears in the graph. |
primitives[].id | string | Kebab-case, globally unique across the workspace. Duplicates are a manifest problem. |
primitives[].name | string | Short human title. |
primitives[].desc | string | One line on what it computes and for whom. |
primitives[].kind | string | e.g. engine, api, pipeline, toolkit, agent, pattern. |
primitives[].paths | string[] | Repo-relative files or directories that are the primitive. |
primitives[].consumes | string[] | Ids of primitives this one builds on; [] for none. Unknown ids are a manifest problem. |
primitives[].status | string | e.g. live, core-only. |
primitives[].terminal | boolean, optional | A delivered surface a human uses directly. Terminal primitives are listed in their own section and excluded from the orphan list, because zero in-edges is their correct end state rather than dormant value. |
A repo without a manifest is silently ignored by the graph: by doctrine,
that absence marks demos and pitch artifacts as outside the substrate.
A deliverables.json in that directory is still checked.
Discovery groups Git worktrees by their real common Git directory and uses
one checkout: the main worktree when present, otherwise the first directory
by name. Separate clones are also grouped when they share a normalized
remote.origin.url, or a local clone's origin points at another discovered
checkout. For a local clone, the source checkout takes precedence. All passes
use that checkout's files, including the manifest, imports, staleness, and
ledger; a note names the ignored directories in --check, --json, and
SUBSTRATE.md. No fetch or other network access is needed.
Directories with the same manifest repo label but no shared Git identity
produce one problem naming their substrate.json files. Exclude unwanted
copies with excludeRepos. The graph keeps the first directory's version of
any colliding id and still validates the other manifests' consumes targets;
the emit gate stays red until the directory conflict is resolved. Duplicate
ids across different repo labels, or inside one manifest, remain problems
naming the exact files.
Config reference
Optional substrate.config.json at the workspace root:
| Key | Default | Meaning |
|---|---|---|
graceHours | 6 | A manifest edit within this many hours of the code change counts as the same shipment and never alarms. |
excludeDirs | [] | Extra exact directory names excluded from top-level manifest and ledger discovery and pruned at every depth of import and non-git staleness walks, on top of the built-ins: node_modules, .git, dist, build, out, coverage, __pycache__. |
excludeRepos | [] | Top-level directory names or patterns (* and ?) excluded from manifest and ledger discovery only, e.g. ["alpha-copy", "wt-*"]. Matching names inside a retained repo are still scanned. |
nonGitRepos | {} | Map of non-git repo dirname → relative code paths its staleness check may walk. Undeclared non-git repos are skipped from staleness with a one-line note, never walked blind. |
Staleness semantics
- Git repos (detected via
git rev-parse, so worktrees and gitfile checkouts count): the alarm fires when the last commit is more thangraceHoursnewer than the manifest file's mtime and at least one committed file other thansubstrate.jsonchanged in that window. A re-commit touching only the manifest is not drift. - Declared non-git repos: file mtimes under the declared paths stand in for commit times. Walks are capped at depth 6 and 5000 entries; a capped walk says so, and its counts are a lower bound.
- git missing from PATH: one note, staleness skipped for git repos —
never per-repo error spam.
--checkexits 0 regardless of what it finds.
Deliverables ledger
Task boards and scratchpads that hold "built but not yet sent" state do not
survive an assistant's context compaction; a file in the repo does. Any repo
under the root — with or without a substrate.json — may carry a
deliverables.json:
{
"deliverables": [
{
"id": "vendor-bundle-round-3",
"recipient": "Avery",
"artifact": "vendor-bundle-2026-08-10.zip",
"builtAt": "2026-08-10T22:00:00Z",
"sentAt": null
}
]
}
Every entry with a builtAt and no recorded ending is an open obligation:
--check prints ALARM: UNSENT deliverable: <repo>/<id> for <recipient> — built <age> ago (<artifact>) at session start, every session, until someone
records how it ended. Create the entry when the build starts, not when it
finishes — the half-built state is exactly what a compaction orphans.
Malformed ledgers and undated entries surface as problems, never alarms and
never crashes: the ledger is read as a regular non-symlink file, capped at
1 MiB and 5,000 entries before it is parsed, and a single malformed or
duplicate-id entry reports only itself — the register keeps alarming every
other open obligation rather than going dark on one typo. builtAt must be a
full ISO-8601 instant. All printed fields are sanitized and hard-capped before
they reach the injected session context.
How an obligation ends
sentAt means one thing: an email left. An obligation that ended any other
way closes on three fields together — closedAt, a closedReason of
superseded / withdrawn / answered-elsewhere, and a closedBecause
sentence a future reader can audit:
{
"id": "billing-preview",
"recipient": "Jordan",
"builtAt": "2026-08-15T15:06:19Z",
"sentAt": null,
"closedAt": "2026-08-20T00:00:00Z",
"closedReason": "superseded",
"closedBecause": "reshaped 8/20 into a self-serve preview the recipient tests directly; the emailed preview no longer exists"
}
Without this, the only way to quiet a superseded or withdrawn item was to
write a send that never happened — a register that had to lie to stay quiet,
whose alarms then read as noise and got skimmed. A closure missing its reason
or its explanation keeps alarming and says what it needs, because going quiet
on a typo is the failure the field exists to end; an entry carrying both
sentAt and closedAt is a problem, since a send and a non-send closure
cannot both be true.
When the evidence proves only the calendar date rather than an instant, use
the day-precision fields sentOn and closedOn with YYYY-MM-DD; never put
a date-only value in an *At field. The *At and *On forms are mutually
exclusive per obligation, and day-precision chronology is exclusive of its
bounds. One seam to know: memory-lint still recognises sentAt only, so an
obligation closed with sentOn/closedOn reads as unsent to that lint until
it is taught the day-precision fields.
An entry may also carry a warning. It rides the alarm line as
⚠ DO NOT SEND COLD: <warning> — for the artifact that is built and still
owed, but whose claims have gone stale underneath it.
The import-scan lint
A graph assembled from hand-written JSON that nobody validates is a map that
lies, and the sweep-before-build doctrine makes that map the mandatory first
step. So the emit verifies itself: for every primitive, it walks the declared
paths, reads each .js / .mjs / .cjs / .ts / .py file with comment and
docstring bodies blanked out, resolves the relative and intra-repo imports it
finds (import, import(), require(), export … from, and Python's
relative, sibling and dotted-package forms), maps each resolved file back to
its owning primitive, and reports a problem when a real import crosses a
primitive boundary that no consumes edge declares:
problem: import lint: tool -> core undeclared (js/src/tool.mjs:1 imports ./core.mjs)
--emit exits 1 on those, so a manifest edit and its emit ride the same
commit and a stale graph blocks instead of regenerating quietly. One line per
from -> to pair, naming the first site, so closing the list is mechanical.
Ownership rules: a declared directory owns the code files under it, the most specific declaration wins (a primitive that names one file keeps it even when a sibling declares the whole directory), and an import into a file no primitive declares is repo-internal and never a miss.
What the lint cannot see — and what therefore stays hand-declared: HTTP seams between processes, Apex and other non-scanned languages, cross-repo imports, and dependencies expressed as file handoffs. A green lint means no import contradicts the manifest, never that the manifest is complete.
The memory lint
Memory files rot the same way manifests do: a memory cites a module that was
since renamed, the index points at a deleted entry, a "still unsent" claim
outlives the send it describes. memory-lint.mjs runs at session start over
~/.claude/projects/<key>/memory/ and alarms on the three staleness classes
it can prove: a missing cited path within a repository at the root or along
the cited path (marked by .git, including a worktree gitfile, or
substrate.json), a MEMORY.md index entry pointing at a missing memory file,
and an "unsent" claim contradicted by a deliverables.json sentAt. Detection is
mechanical and precision-first — URLs, API-endpoint fragments, command lines
with flags, and citations without a workspace anchor stay silent, because a
false alarm teaches the reader to ignore the channel. A plain directory such
as a home directory's bin/ does not anchor a citation. For spans such as
tools/run.py check and tools/run.py:12 SYMBOL, only the leading path is
checked; later slash-bearing words do not create citations. Repeated references
to the same normalized path in one memory count as one finding, including
references with different line numbers or slash styles. The lint never rewrites
a memory: the agent reading the alarm owns the repair, with the truth in
front of it. Echoed content is control-character-stripped and capped, and the
exit code is 0 unconditionally — a lint that can wedge a session start is
worse than the staleness it reports.
The prose-smell gate
prose-smell.mjs rides every Write/Edit of a prose file (.md,
.txt, .html, .eml) as a PostToolUse hook and fails the write (exit 2,
findings on stderr) when the text carries a frame that marks generated
prose. It is a regex gate and says so: it catches spellings, and the
WRITING doctrine snippet covers what a regex cannot.
| Rule | Severity | What it matches |
|---|---|---|
negation-contrast, more-than-just | HIGH | the canonical contrastive frame: isn't just about X — it's about Y, is more than just |
contrast-comma, contrast-slogan | HIGH on one hit | the slogan spellings of the same frame: a computation, not a decision; The product is not the gap. A comparand that keeps going (, not the number the vendor quoted in …) is a sentence and is left alone, as is a qualifier (counsel, not full-time, , not yet, , not because …). |
rather-than, instead-of, contrast-never, no-and-no, would-rather-than | by density | the connective spellings, counted together per 500 words with the denominator floored at 500: two or three is MEDIUM, four or more is HIGH. One rather than in a page is English. |
whether-your, assistant-artifact, stock-vocab | HIGH | inclusive triples, leftover assistant phrasing, stock LLM vocabulary |
from-to-coverage, concluder, em-dash-density, aphoristic-restatement | MEDIUM | advice: a coverage opener, a summary opener, heavy em dashes, a slogan of eight words or fewer with a copula right after a sentence of twenty-five or more |
Fenced code blocks and inline `code` spans are blanked before
scanning, with offsets preserved, so a bug report that quotes the tell it
is about is not flagged for quoting it and every reported line number is
real.
In-house prose. Dense engineering documentation uses X, not Y as a
precision device several times a page, and a gate that failed every edit
to such a tree would be routed around. A repo therefore declares its own
docs in-house with a .prose-smell.json at its root:
{ "contrastive": "advise" }
The file is found by walking up from the scanned file's directory. Under
it, the contrastive family is reported at MEDIUM (every finding still
prints, tagged in-house: advised) and the exit code stays 0; every other
rule keeps its severity. An artifact drafted outside such a tree gets the
default, which fails. A malformed config, an unknown key, or an unknown
mode is reported on stderr and ignored — the default applies — because a
config that could silently switch the gate off would be the inverse of
what a config is for.
Mail-sender gate
The gate is a tripwire. It is not a security boundary. It stops a script or an agent reaching a mail provider by an ordinary route outside the declared sender, and a command written to evade it gets through. Keep provider credentials where only the sender can use them, and treat this hook as the alarm in front of that.
Opt in with .send-chokepoint.json in the project or a parent directory:
{
"sender": "tools/outbound_mail.py",
"exempt": ["services/mailer"],
"providers": ["graph.microsoft.com"]
}
The Bash|PowerShell|Monitor pre-tool hook (a Monitor command runs in the same
shell as Bash; a Monitor WebSocket source runs none and passes) checks inline commands, readable referenced
scripts and their supported local imports for both a watched provider host and
a recognized send verb. A detected send outside the declared paths exits 2
before execution. sender is an exact path; each exempt entry also permits
its descendants. Paths resolve against the configuration's directory, so a
same-named file elsewhere does not inherit permission. Leading cd commands
and Windows paths are covered by regression fixtures.
A Bash command made only of read-only tools is not judged, so the files the
gate protects can still be read, searched, hashed and compared. The exemption
reads bash grammar, so it covers the Bash tool alone: PowerShell takes no
backslash as an escape, and a separator bash reads as text can end a
PowerShell statement, so every PowerShell line is judged on its full text.
The tools are
cat, head, tail, wc, grep, egrep, fgrep, sha1sum,
sha256sum, sha512sum, md5sum, b2sum, cksum, diff, cmp, comm,
file, stat, ls, readlink, realpath, basename, dirname, du,
od, xxd, hexdump, strings, uniq, cut, nl, echo, printf and
true, each named bare, so ./cat is judged. They may be joined by pipes,
;, &&, ||, & and newlines, nested in $(...), and run under
timeout or nice with their documented options or under env with no
option. Quoting and line continuations are read as bash reads them, so
grep -E "send|reply" sender.py is exempt while cat sender.py | python3
and echo $(python3 sender.py) are judged. The whole line is judged when any
of these holds:
- It runs any other program. That includes
git, whose every subcommand honours repository configuration an agent can write, andrg,ugrep,sort,less,more,sedandawk, which each have an option or command that runs a program. - A read-only tool carries an option that runs a program or writes a file,
in any abbreviation:
grep,egreporfgrepwith--filter,--pager,--view,--config,---FILE,-Q/--queryor--save-config(in the Claude Code shellgrepruns ugrep, where these name a command, load a file that can, start a TUI that runs a pager on a key press, or write a file),diffwith-lor--paginate(it runspr), orfilewith-z,-Zor--uncompress(it runs decompressors found onPATH). One of these tools' arguments being an expansion or a glob judges the line too, since it could produce such an option. uniqorxxdhas an argument that is not an ordinary file, as defined for redirections below: each writes its second operand.- It assigns a variable:
NAME=value,env NAME=value,printf -v NAME, or a{NAME}>redirection. envcarries an option (-Ssplits its value into a new command line), ortimeoutornicecarries one they do not document.- A redirection opens anything except an ordinary file or
/dev/null,/dev/stdin,/dev/stdoutand/dev/stderr:/dev/tcp/HOST/PORTand/dev/udp/HOST/PORT, which bash opens as sockets, any other path under/dev,/procor/sys, or a target built from an expansion.<&and>&may also duplicate descriptor 0, 1 or 2 or close one with-; any other descriptor was inherited from whatever started the shell. bash expands a>&target that is not a descriptor a second time, so a file named there must be plain text: letters, digits and_ . / , : = % + @ -. - The reader cannot follow it: backquotes, arithmetic, subshells, process
substitution,
${...}operators, a backslash-newline right after$,&or a redirection operator (bash joins it to what follows), an unterminated quote or heredoc, or nesting deeper than the reader's stack.
A heredoc body is data. A line whose commands are all read-only is not judged
when it writes a heredoc, so cat > probe.py <<EOF may write a script that
names a sender. bash expands an unquoted body, so one holding a command
substitution, arithmetic, a ${...} operator or a trailing backslash leaves
the line judged. A body is quoted, and always data, when a quote or an
escaping backslash appears in its delimiter (<<'EOF', <<"EOF",
<<\EOF); a backslash-newline there quotes nothing. Any other line is
judged on its full text, bodies included, so python3 <<EOF and a
write-then-run in one command are still caught.
Omit providers to use the built-in Microsoft Graph, Gmail, SendGrid, Mailgun,
Postmark and Resend hosts. This is a static routing check: it does not send
mail, grant authorization, verify recipients or prove that the approved
sender enforces its own checks. Dynamic URLs and unsupported imports can
escape detection. Do not treat it as an egress sandbox. Its tests use local
fixture files and never contact mail providers.
Missing configuration allows execution. Configuration problems are reported
when observed, and a config that is not valid JSON is ignored. In a repo that
opted in, a command the hook cannot finish judging is blocked: a payload
with no shell command, a config or script it cannot read, a script or
imported module it cannot stat (one behind a directory it cannot search, or
on a path longer than the system allows) or one over 16 MiB (the declared
sender and exempt paths aside), an error in the hook, or no answer after 8
seconds. Claude Code runs a command whose hook crashes or outlives the 15
seconds hooks.json allows, so the judgment runs in a child process with a
512 MB heap that the hook kills at its 8-second deadline.
These shapes are not resolved, and a sender reached through one of them runs:
- A script name the line builds instead of spelling it as one plain word:
quotes or escapes inside it (
bl''ast.py), variables, brace lists, globs,~, command output, or a directory entered other than by onecd DIR. An unquoted name with a non-ASCII character (envoi_relancé.py) is not read as a word at all. - Code run by a tool the hook is not routed to. The matcher names
Bash,PowerShellandMonitor; a REPL tool that runs model-written code directly is not judged. - A path the line enters through a symlink and then leaves with
.., or reaches through/proc/self. The gate resolves..as text and bash through the filesystem. The sanctioned-sender match also ignores case, so on a case-sensitive filesystem a differently cased path passes as the sanctioned one. - Shell code in a string another program runs (
sh -c,bash -c,env -S,eval,trap,find -exec,xargs), fed to a shell through a pipe, heredoc, here-string or process substitution, or written to a file by the same line that runs it. - Code an interpreter finds itself:
python3 -m mod,-c 'import mod',perl -Mmod, a script without an extension or with one other than.py,.mjs,.js,.ts,.ps1,.shor.rb, and any import but a Pythonimportof a module beside the script. - A provider host or send verb assembled at run time, so the two never appear together as text.
- A nearer
.send-chokepoint.json, or an edit to this one: the config sits in a tree the agent can write. - A config in a directory the hook cannot search. The hook sees no opt-in there, so a line that opens the directory and runs a sender inside it is allowed unless a config above the directory applies.
- Any route but a watched host with a recognized verb: SMTP, a relay, or another provider's API.
A Monitor command gets no read-only exemption, so a log watcher whose filter names a send endpoint is refused where the same line under Bash is allowed.
Honest limitations
- Discovery is exactly one level under the root:
root/repo/substrate.json. Nested workspaces and deeper monorepo packages are not scanned. - Staleness is a heuristic. It compares the manifest file's mtime against commit times (git) or file mtimes (declared non-git), so a fresh clone or copy — which resets mtimes — can defer a legitimate alarm until the next real commit. It answers "did code move after the manifest," never "is the manifest's content correct."
- Non-git mtime semantics count file entries newer than the manifest, which is not identical to git's distinct-files-changed count.
- The graph is only as true as the manifests. The doctrine snippet
(
templates/DOCTRINE.snippet.md) is the enforcement mechanism: manifest updates ride the same commit as the primitive they describe.