Skip to content
v1.10.4Apache-2.0

Multi-repo primitive registry: each repo declares its reusable primitives in a substrate.json manifest, a zero-dependency generator compiles them into one SUBSTRATE.md graph — nodes, edges, orphans as dormant value, hubs to harden first — and staleness alarms surface at every session start so new work plugs into what exists instead of running parallel to it. A per-repo deliverables.json ledger rides the same check: anything built but not yet sent alarms at session start until the send is recorded. A prose-smell gate rides every Write/Edit: outward prose that carries AI-writing frames (contrastive negation, whether-you're triples, assistant artifacts, stock LLM vocabulary) is bounced back for a rewrite before it can ship. A memory lint rides the same session-start channel: memory files citing paths that no longer exist, index entries pointing at deleted memories, and unsent claims contradicted by the deliverables ledger all alarm until the agent repairs them - detection is mechanical, repair is never.

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. --emit writes SUBSTRATE.md and exits 1 on manifest problems (the CI half); --check prints the summary and always exits 0 (the session half); --json prints 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>, else CLAUDE_PROJECT_DIR, else the current directory.
  • hooks/hooks.json — one hook set for Claude Code and Codex. SessionStart with no matcher, so startup, resume, clear, and post-compaction all get the --check output; the output is factual statements only, and a stale manifest is reported, never commanded. A second SessionStart hook runs memory-lint.mjs over the workspace's memory files, and a PostToolUse hook on file writes runs the prose-smell gate (both below). A PreToolUse hook on Bash|PowerShell|Monitor runs 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 matching skills/substrate-<name>/ skill.
  • templates/ — DOCTRINE.snippet.md, the sweep doctrine, ready to append to a workspace CLAUDE.md or AGENTS.md; WRITING.snippet.md, the outward-prose rules a regex cannot check.
  • tests/ — node:test suites over temp-dir fixture workspaces; wired into this repo's scripts/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:

KeyTypeMeaning
repostringDisplay and sort name for the repo (usually the dirname).
versionnumber, optionalSchema version; 1 is tolerated and currently the only value.
primitivesarrayThe repo's reusable primitives. Empty is valid — the repo still appears in the graph.
primitives[].idstringKebab-case, globally unique across the workspace. Duplicates are a manifest problem.
primitives[].namestringShort human title.
primitives[].descstringOne line on what it computes and for whom.
primitives[].kindstringe.g. engine, api, pipeline, toolkit, agent, pattern.
primitives[].pathsstring[]Repo-relative files or directories that are the primitive.
primitives[].consumesstring[]Ids of primitives this one builds on; [] for none. Unknown ids are a manifest problem.
primitives[].statusstringe.g. live, core-only.
primitives[].terminalboolean, optionalA 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:

KeyDefaultMeaning
graceHours6A 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 than graceHours newer than the manifest file's mtime and at least one committed file other than substrate.json changed 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. --check exits 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.

RuleSeverityWhat it matches
negation-contrast, more-than-justHIGHthe canonical contrastive frame: isn't just about X — it's about Y, is more than just
contrast-comma, contrast-sloganHIGH on one hitthe 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-thanby densitythe 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-vocabHIGHinclusive triples, leftover assistant phrasing, stock LLM vocabulary
from-to-coverage, concluder, em-dash-density, aphoristic-restatementMEDIUMadvice: 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, and rg, ugrep, sort, less, more, sed and awk, 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, egrep or fgrep with --filter, --pager, --view, --config, ---FILE, -Q/--query or --save-config (in the Claude Code shell grep runs 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), diff with -l or --paginate (it runs pr), or file with -z, -Z or --uncompress (it runs decompressors found on PATH). One of these tools' arguments being an expansion or a glob judges the line too, since it could produce such an option.
  • uniq or xxd has 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.
  • env carries an option (-S splits its value into a new command line), or timeout or nice carries one they do not document.
  • A redirection opens anything except an ordinary file or /dev/null, /dev/stdin, /dev/stdout and /dev/stderr: /dev/tcp/HOST/PORT and /dev/udp/HOST/PORT, which bash opens as sockets, any other path under /dev, /proc or /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 one cd 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, PowerShell and Monitor; 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, .sh or .rb, and any import but a Python import of 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.