The very first action in any task that will edit files must be:
test -d "$(git rev-parse --show-toplevel)/.git" && echo main || echo linked- prints
main→ you are in the main worktree. Stop. Rungit worktree add /tmp/librefang-<feature> -b <feature-branch> origin/mainand continue all work from that path. - prints
linked→ you are in a linked worktree. Continue.
Git stores the main worktree's .git as a directory and a linked worktree's .git as a text file, so the directory test is true exactly in the main worktree.
Do not substitute git rev-parse --git-dir (path-shaped output, varies with cwd) or path-matching against pwd (every clone lives somewhere different).
.claude/hooks/ (Claude Code PreToolUse / SessionStart) and scripts/hooks/ (version-controlled git hooks) enforce this contract in two independent layers.
Full enumeration: docs/development/ai-safety-hooks.md.
The short list of things that get blocked:
- Editing files or running mutating git commands in the main worktree.
- Force-push to
main/master;--no-verify/--no-gpg-signon any git command. - Staging sensitive files (
.env*,*.pem,id_rsa,credentials*, …) and broadgit add -A/git add .— stage specific paths. - Claude / Anthropic attribution in a commit message, or a commit author identity that resolves to Claude / Anthropic.
rm -rfagainst dangerous targets (/,~,$HOME,target,.git,/usr,/etc, …).- Launching the daemon (
librefang start,target/*/librefang start|daemon) — port 4545 belongs to the user's session, and live testing is human-only. - A changelog fragment in an unrecognised
changelog.d/section directory, or an[Unreleased]addition missing(@user)attribution.
Enable the git-side hooks once per clone: just setup (or cargo xtask setup), which sets git config core.hooksPath scripts/hooks.
LibreFang is an open-source Agent Operating System written in Rust (29 crates in crates/, plus xtask/).
- Config:
~/.librefang/config.toml - Default API:
http://127.0.0.1:4545 - CLI binary:
target/release/librefang,.exesuffix on Windows (debug builds at the matchingtarget/debug/path)
- Core types & utilities:
librefang-types,librefang-http,librefang-wire,librefang-telemetry,librefang-testing,librefang-import,librefang-subprocess(JSON-over-stdio transport for sidecar bridges) - Kernel:
librefang-kernel(orchestration),librefang-kernel-handle(trait used by runtime to call kernel without circular dep),librefang-kernel-router,librefang-kernel-metering - Runtime:
librefang-runtime(agent loop, tools, plugins, OAuth, WASM sandbox),librefang-runtime-mcp,librefang-runtime-audit,librefang-runtime-media,librefang-runtime-sandbox-docker - LLM drivers:
librefang-llm-driver(trait + error types — interface only) andlibrefang-llm-drivers(concrete provider impls: anthropic, openai, gemini, …) - Memory:
librefang-memory(SQLite substrate),librefang-memory-wiki(durable markdown knowledge vault) - Surface:
librefang-api(HTTP server + dashboard SPA atcrates/librefang-api/dashboard/),librefang-cli,librefang-desktop,librefang-acp(ACP adapter for Zed / VSCode / JetBrains) - Extensibility:
librefang-skills,librefang-hands,librefang-extensions,librefang-channels,librefang-rl-export
Do NOT run cargo build or cargo run locally.
cargo test is allowed only when scoped with -p <crate> / --package <crate> — the unscoped workspace-wide form contends with the user's other sessions on the shared target/ directory.
Full workspace build / test runs in CI.
After every change:
cargo check --workspace --lib # Compile-check only
cargo clippy --workspace --all-targets -- -D warnings # Zero warnings
cargo test -p <crate> # Only when verifying behavior in one crate
# Quick unit-only lane, mirrors CI's Test / Unit (lib+bin):
cargo nextest run --workspace -E 'kind(lib) | kind(bin)' --no-fail-fastdocs/development/build-and-verify.md covers the rest: the two CI test lanes and why the nextest filter expression is used instead of --lib --bins, the librefang-desktop Windows exclusion (#6729), and how to verify without a native toolchain via Dockerfile.rust-dev and a per-worktree target volume.
Read it before declaring a change unverified on a host that has no cargo.
Primary verification is automated: #[tokio::test] coverage in crates/librefang-api/tests/ exercises every major route domain against a real axum router via TestServer (start_test_server* in tests/api_integration_test.rs).
For any route / wiring change:
- Add a
#[tokio::test]againstTestServerin the matchingtests/*.rsfile — spawn the router, hit the endpoint withreqwest, assert status and shape; for write endpoints, follow up with a read and assert the side effect. This is what catches missingserver.rsregistrations, un-deserialized config fields, kernel↔API type drift, and empty/null payloads. - Run scoped tests locally:
cargo test -p librefang-api. - Reviewers gate PRs on the presence of an integration test for each new endpoint.
Live daemon + real LLM is HUMAN-only. It is needed only for LLM call paths or end-to-end prompt/metering wiring that integration tests can't simulate.
Claude must not execute it — prepare the commands from docs/development/build-and-verify.md and hand them to the user.
- Deterministic prompt ordering (#3298): anything reaching an LLM prompt — tool definitions, MCP server summaries, skill / hand registries, capability lists, env passthrough lists — MUST be ordered before stringifying.
Prefer
BTreeMap/BTreeSetoverHashMap/HashSetso the compiler enforces it; otherwise sort at the boundary. HashMap iteration order varies across processes and silently invalidates provider prompt caches even when content is unchanged. Regression tests sit next to each boundary —kernel::tests::mcp_summary_is_byte_identical_across_input_orders,kernel::tests::mcp_summary_inner_tool_list_is_sorted,librefang_skills::registry::tests::all_tool_definitions_is_deterministic_across_insertion_orders. - Agent workspace layout: identity files (SOUL.md, IDENTITY.md, …) live in
{workspace}/.identity/, not the workspace root.read_identity_file()checks.identity/first and falls back to root for pre-migration workspaces;migrate_identity_files()runs on every spawn. - Named workspaces (
[workspaces]in agent.toml): shared directories declared withpath(relative toworkspaces_dir) andmode(rw/r). Agents sharing a path never collide — identity files stay in their private.identity/. Resolved absolute paths are injected into TOOLS.md as@name → /abs/path (mode). Seeworkspace_setup.rs: ensure_named_workspaces(). KernelHandletrait avoids circular deps between runtime and kernel;AppStateinserver.rsbridges kernel to API routes.- Adding a route: there is no single
routes.rs. Handlers live incrates/librefang-api/src/routes/, split per domain (agents.rs,memory.rs,system.rs, …), each exporting its ownrouter();server.rs::api_v1_routes()composes them with.merge(), and some domains nest a second level (routes/system.rs::router()mergesagent_templates,approvals,pairing, …). Implement the handler in the matching domain module and merge itsrouter(). Drift guards:tests/dead_route_audit_test.rsandtests/openapi_path_coverage_test.rs. - Auth middleware allowlist: unauthenticated endpoints go in the
is_publicallowlist inmiddleware.rs— NOT by reordering routes inserver.rs. The auth layer applies to all routes. - Dashboard is a React + TanStack Query SPA (not Alpine.js) in
crates/librefang-api/dashboard/. See itsAGENTS.mdfor detail.- All API access in pages/components MUST go through hooks in
src/lib/queries/andsrc/lib/mutations/. No inlinefetch()orapi.*calls. - Query keys always come from the factories in
src/lib/queries/keys.ts— never inline["foo","bar"]. Every factory is hierarchical (all/lists()/list(filters)/details()/detail(id)) soinvalidateQueries({ queryKey: xxxKeys.all })invalidates the whole domain. - Every side-effecting mutation calls
invalidateQuerieswith factory keys inonSuccess/onSettled, colocated with the mutation hook rather than at call sites.
- All API access in pages/components MUST go through hooks in
- Config fields need all four: struct field,
#[serde(default)],Defaultimpl entry,Serialize/Deserializederives. - Trait injection pattern: when runtime needs functionality from extensions/kernel, define the trait in runtime and implement it in kernel (e.g.
McpOAuthProvider). Never make runtime depend on extensions (circular dep). - Docker callback URLs: never bind ephemeral localhost ports for OAuth callbacks in daemon code — unreachable from outside Docker. Route callbacks through the API server's existing port.
- MCP OAuth flow is entirely UI-driven: the daemon only detects 401 and sets
NeedsAuth. PKCE + callback are handled by the API layer (routes/mcp_auth.rs); Dynamic Client Registration (RFC 7591) kicks in when a server has aregistration_endpointbut noclient_id. session_mode(inagent.toml, notconfig.toml) decides whether an automated invocation reuses the persistent session ("persistent", default) or creates a fresh one ("new"). Honoured by event triggers,agent_sendand cron jobs; ignored by channel messages (alwaysSessionId::for_channel) and forks (forcedPersistentfor prompt cache). Resolution order, the cronPersistent-vs-Newsession-id behaviour, and thecron_fire_session_overridehelper:docs/architecture/session-mode-resolution.md. When creating a trigger or cron, pick consciously —Persistentfor continuity and cache reuse,Newfor isolation.- Per-agent
proactive_memory/skill_workshop/compactionoverrides live inagent.toml, NOTconfig.toml(#5476).KernelConfighas noagentsfield, so[agents.<name>.<key>]inconfig.tomlparses but never reaches anyAgentManifest. The kernel emits a targetedWARNat boot and onPOST /api/config/reload(KernelConfig::detect_misplaced_per_agent_overrides). Inside aHAND.tomlthe[agents.<name>.<key>]form is read, becauseHandManifestdoes have anagentstable. - Message-history trim cap is per-agent (
agent.toml: max_history_messages) and global (config.toml: max_history_messages). DefaultDEFAULT_MAX_HISTORY_MESSAGES = 60; values belowMIN_HISTORY_MESSAGES = 4are clamped up with a warning. Resolution: agent override > kernel config > compiled default. Seedocs/architecture/message-history-trimming.md. - Trigger dispatch concurrency has three layered caps scoped to the trigger dispatcher only —
agent_send, channel bridges and cron still serialize on the existing per-agent / per-session locks insidesend_message_full. GlobalLane::Triggersemaphore (config.toml: queue.concurrency.trigger_lane, default 8), per-agent semaphore (agent.toml: max_concurrent_invocations, fallbackqueue.concurrency.default_per_agentdefault 1), per-session mutex.persistent+ cap > 1 auto-clamps to 1 with aWARN(concurrent writes to one session's history are undefined). Per-agent caps are not invalidated on manifest hot-reload — kill the agent and let it respawn, or restart the daemon; an in-place activate/status flip silently keeps the old cap. Seedocs/architecture/trigger-dispatch-concurrency.md. - Config hot-reload classification — which
KernelConfigfields hot-reload, which need a restart, which are read-live/noop — is decided bybuild_reload_planincrates/librefang-kernel/src/config_reload.rs. Consult the drift-guarded table atdocs/operations/config-reload.mdbefore assuming a config edit takes effect onPOST /api/config/reload. - Skill workshop (#3328) passively captures teaching signals from successful turns into draft skills under
~/.librefang/skills/pending/<agent>/<uuid>.toml. Default-OFF — opt in per agent with[skill_workshop] enabled = trueinagent.toml(or the matching[agents.<name>]section of aHAND.toml); source of truth isSkillWorkshopConfig::default()incrates/librefang-types/src/agent.rs. Approval routes throughevolution::create_skill, so the prompt-injection scan runs at bothsave_candidateandapprove_candidate— every artefact the agent can see has crossed the same security boundary as a marketplace skill.review_mode = "threshold_llm"uses theAuxTask::SkillWorkshopReviewslot on the cheap-tier provider chain, and returnsIndeterminaterather than billing the primary provider when no cheap-tier credentials exist (financial-DoS guard). CLIlibrefang skill pending list / show / approve / reject; HTTPGET/POST /api/skills/pending[…]; dashboardPendingSkillsSection. Seedocs/architecture/skill-workshop.md.
- Format: conventional commits (
feat:,fix:,docs:,refactor:,chore:,ci:,perf:,test:). - No AI / Claude attribution in commit messages, PR bodies, or comments — the
commit-msghook enforces it, and so does the PreToolUse Bash hook. - Worktree:
git worktree addon an external disk for new features, falling back to/tmp/librefang-<feature>. Never develop on the main worktree. - Worktree continuation = drive to PR. When continuing half-done work (uncommitted changes or unmerged commits), the workflow is commit → push → open or update PR; don't stop at "local commits only".
Everything left in the worktree counts as real work, including a regenerated
Cargo.lockafter a rebase — commit it, don'tgit checkoutit away. - Re-entering a worktree you did not just create →
git fetchand diffHEAD..origin/<branch>BEFORE editing. Collaborator pushes, theauto-update-branchescron, and theopenapi-driftauto-codegen commit all move a remote tip without your involvement. Editing on a stale base and force-pushing silently clobbers that newer work; a non-fast-forward rejection is the last line of defense, not the plan.
Appending to the single ## [Unreleased] section conflicts with every other open PR doing the same, in a file where the conflict carries no information — both sides are correct and the resolution is always "keep both".
Write one fragment per entry in the section directory matching its ### heading (added/, fixed/, changed/, security/, documentation/), named after the PR or issue number so fragments sort usefully: changelog.d/fixed/6623-wire-max-content-chars.md.
The file holds the bullet body without the leading - , one sentence per line, continuation lines indented two spaces, ending (#PR) (@your-github-login).
A fragment in an unrecognised section directory is rejected by scripts/check-changelog-attribution.py, because assembly has no heading to render it under and would drop it silently.
Format and a worked example: changelog.d/README.md.
The entry ends up in the GitHub release body verbatim, so write prose that explains why, not a restatement of the PR title.
cargo xtask collect-fragments folds the fragments into ## [Unreleased] and deletes the files consumed; cargo xtask release runs it before cutting the dated section, and .github/workflows/release.yml slices that section into the release notes, Dev.to article and social post.
Generated - <PR title> (#N) (@author) lines fill only the gaps: a PR whose number appears in a curated bullet's trailing (#N) group gets no generated line, so it is described once, in the words someone chose.
Do not hard-wrap at 72, 80, 100, or any other character count.
The only legitimate line break inside a paragraph is at a complete sentence boundary — one sentence, one line, regardless of length.
Hand-tuned column wraps split noun phrases at awkward points, force a single-word edit to re-flow the whole paragraph (polluting git blame and review diffs), and carry no semantic information.
This applies to markdown anywhere in the repo, CHANGELOG.md bullets, PR titles / bodies / comments posted via gh, source doc-comments (//!, ///, JSDoc, …) and any multi-line prose comment block, and commit message bodies.
Commit subject lines still follow git's ~72-char display convention — that is a tooling limit, not prose wrapping.
The rule applies to new prose and to any paragraph you are already touching. Files written under the old convention are not retroactively rewrapped; don't rewrap an untouched paragraph just to enforce this.
Full policy, with the incidents that produced each rule: docs/development/github-collaboration.md.
The rules you must not break without asking:
- Don't close, reassign, re-label or re-milestone PRs / issues opened by others unless the maintainer instructs you to. Recommend closure in a comment instead; when directed to close, state the substantive reason (review bugs, superseded by, scope mismatch).
- Force-push only to your own branches, only before review. After a reviewer has loaded the diff, prefer fixup commits or a follow-up PR.
- One PR ↔ one issue (or one tight cluster). Don't bundle unrelated refactors.
- Fix what you found — don't punt it to a "follow-up". Anything you noticed while reading or writing the code in this PR is in-scope by definition: review nits, wrong HTTP status codes, missing log fields, redundant lookups, stale comments, small clippy noise. "Follow-up", "next PR", "future cleanup", "leave for later" are red flags in your own output — they almost always mean "I saw the problem and decided to defer". Bar to defer: would fixing it require touching a different crate or domain than the one you're already in? If no, fix it now; if yes, ask the human with the concrete trade-off rather than deciding unilaterally. Re-classifying a deferred item as a "non-issue" requires file/line evidence in the same response — "I looked again and it's fine" is another form of punting.
- PR body must enumerate substantive changes, verification performed (integration test names,
cargo check --workspace --lib, scopedcargo test -p <crate>), and any deferred work. Bullet form, no marketing prose. - CI polling budget: ~5 minutes total, in 60–270 s chunks (Anthropic's prompt cache TTL is 5 min — keep each wake-up inside it).
Then push, leave the run URL, and stop.
Don't pre-emptively re-run a check that hasn't failed; retry once, after a recorded failure.
Don't open follow-up issues or pivot the plan while waiting — report status and yield.
Don't add reviewers, flip
ready-for-review, orgh pr readyon someone else's behalf. - At most two follow-up comments on a thread without human input, then stop. No "looks good" drive-bys. Every reply links evidence: commit SHAs, file paths, test names.
- Latest maintainer intent wins in conflict resolution, and preserve both sides' intent — dropping a hunk because "it'll be reapplied later" is how regressions land.
- Batch merging is runner-pool bound, not merge bound. Merging >10 PRs back-to-back saturates the free-plan
ubuntu-latestpool; merge in batches, cancel superseded runs (never a run whosehead_shais its branch tip —CI Gatefails oncancelledand does not re-evaluate), and remember a stalled queue is not a CI failure. - Two green PRs can still break
maintogether. Themainruleset has nostrict_required_status_checks_policy, so each PR merges on CI run against its own base. Group a merge sweep by changed file, re-run CI on later PRs in a group, and verifymainitself after the batch.
- Windows:
librefang.exemay be locked while the daemon runs — usecargo check --libor kill the daemon first. (Linux / macOS let you overwrite a running binary.) - Windows: use
taskkill //PID <pid> //F(double slashes in MSYS2 / Git Bash). PeerRegistryisOption<PeerRegistry>on the kernel butOption<Arc<PeerRegistry>>onAppState— wrap with.as_ref().map(|r| Arc::new(r.clone())).- A new
KernelConfigfield MUST also appear in theDefaultimpl or the build fails. AgentLoopResult's field is.response, not.response_text.- The CLI subcommand to start the daemon is
start, notdaemon. Option<Arc<dyn Trait>>fields on structs derivingSerialize/Deserialize/Clone/Debugneed#[serde(skip)]plus manual impls of the affected traits.ErrorTranslator(fromRequestLanguage) is!Send— every.awaitmust happen AFTERdrop(t), or the axum handler fails with a crypticHandler<_, _>trait bound error.LIBREFANG_VAULT_KEYmust base64-decode to exactly 32 bytes (openssl rand -base64 32gives 44 chars). 32 ASCII chars ≠ 32 bytes.- Linux desktop:
zbus/tokioconflicts with Tauri's worker threads — a blocking session-bus connection (tauri-plugin-notification / notify-rust) inside a Tokio worker panics with "Cannot start a runtime from within a runtime." Force theasync-iobackend of the zbus / ksni crate so it runs on a separate reactor. CLAUDE_CODE_HOMEoverrides the home directory theclaude-codedriver hands to the spawned AnthropicclaudeCLI. This is a LibreFang-private contract — the Anthropic CLI does not read this variable; the driver resolves it kernel-side and projects it onto the platform-native home var ($HOME/%USERPROFILE%) before spawn. Distinct fromLIBREFANG_HOME, which relocates the daemon's data dir (crates/librefang-kernel/src/config.rs: librefang_home); the two never share a value. It exists for containers that drop to a numeric uid without a passwd entry and inherit a placeholder home (/nonexistent,/var/empty,/dev/null), leaving the CLI unable to find~/.claude/.credentials.json. The override is ignored when the inherited home is already a real directory, and when it points at a non-directory the driver logs aWARNand falls back rather than honouring it.- When parallel agents modify the same crate,
Option::Nonedefaults for new fields compile silently but disable the feature. Always write the integration test at the injection site, not just the implementation site.