Quality gates for mobile apps, answering one question: is this app ready to ship? crucible
builds an Android (Gradle) or iOS (xcodebuild) app and deploys it to devices through
linc-nerve, runs your lint and tests and folds them into a
7-dimension quality score with a ship gate at 85, checks app-store readiness (5 dimensions,
threshold 80%), runs YAML build-verification tests (BVT) against visual and accessibility-tree
baselines, and pulls ranked marketing screenshots out of a screen recording. It is one layer of a
4-layer stack: linc-device-kit (shared models) → linc-nerve (device I/O) → linc-crucible
(quality). crucible itself contains no device code; everything that touches a device goes through
the nerve daemon's HTTP API.
8 MCP tools | Python 3.13+ | Android + iOS
Requires Python 3.13+ (python3 --version); with pipx/uv, --python 3.13.
pipx install linc-crucible(orpipx install --python 3.13 linc-crucible)uv tool install linc-crucible(oruv tool install --python 3.13 linc-crucible)pip install linc-crucible(inside a virtual environment)
AI-ranked frame picks need the unpublished linc-ai package; without it crucible_extract_assets uses evenly-spaced frames and reports fallback_used: true.
crucible discovers projects by scanning $LINC_PROJECTS_DIR/*/linc-manifest.yaml (default
~/projects/*/linc-manifest.yaml). Two files live in the project root:
linc-manifest.yaml— identity.identity.nameis the project name every command takes;identity.packageis the Android applicationId / iOS bundle ID..forge.yaml— holds the build/test/lint commands; the name is historical. Commands in.forge.yamlrun from the project root, so the./gradlewexample below requires a Gradle wrapper (./gradlew) in that directory. When neitherlint_commandnortest_commandruns to a measurement (nothing measured),crucible assayreports"status": "unmeasured"and exits1.
# ~/projects/myapp/linc-manifest.yaml
schema_version: 1
identity:
name: myapp
package: com.example.myapp# ~/projects/myapp/.forge.yaml
forge:
build_command: "./gradlew :app:assembleDebug"
test_command: "./gradlew test"
lint_command: "./gradlew lint"For crucible preflight --project myapp, place store metadata in <project>/store/:
<project>/store/store-listing.md— markdown with headings# Title,# Short Description,# Full Description,# Keywords,# Category.<project>/store/brand-brief.json— JSON object with required fieldsapp_name,tagline,emotion,primary_color,accent_color,background_color,icon_concept,style,platforms.
Apps with a phone and a watch module sharing one package ID can declare modules: in
.forge.yaml (name, device_types, build_command, apk_pattern) so each artifact is routed
to the right device type: crucible pour --project myapp --module watchApp --device-type watch.
After Project setup above (myapp has
linc-manifest.yaml and .forge.yaml in $LINC_PROJECTS_DIR/myapp), the first four commands
need no daemon, device or Docker:
crucible fuelcheck
crucible assay --project myapp
crucible score myapp
crucible preflight --project myapp
# pour, bvt and baseline capture reach devices through the nerve daemon:
pipx install "linc-nerve>=0.1,<0.2" && nerve daemon &
claude mcp add linc-crucible -- crucible bellowscrucible fuelcheck reports which build tools, projects and services it can see and exits 0 on
a fresh machine. Seven of the 8 MCP tools map directly to CLI commands (crucible_extract_assets →
crucible extract-assets), while crucible_baseline is served by crucible baseline (capture,
list, prune; see MCP Tools); options are
flags, not positionals (crucible pour --project myapp), except crucible score PROJECT [--history] [--limit N] and crucible baseline. Output is JSON on stdout; for text, put
--human before the command (crucible --human score myapp). Exit 0 success (status: "partial" stays exit 0 with ship_gate: false in the payload), 1 tool error (a run that measured nothing, or a BVT that did not run, exits 1), 2 usage error. crucible schema --json
prints every command and option.
Seven of the 8 MCP tools are reflected directly as CLI commands (crucible_pour → crucible pour).
crucible_baseline is served on the CLI by the hand-written crucible baseline group (capture,
list, prune for screenshot baselines); its a11y mode and latest / promote actions are
MCP-only.
| Tool | Needs nerve daemon | Description |
|---|---|---|
crucible_pour |
yes (no with build_only) |
Build, install and launch on devices (launch_intent deep-link URI, e.g. myapp://settings). Android/iOS, phone/watch routing, build_only and skip_build modes |
crucible_assay |
no (live a11y tree check uses it when running) | Quality analysis: lint, tests, optional security (MobSF), perf, static a11y, live a11y tree (a11y_tree honours the personal-device policy; allow_personal=True overrides). Modes: quick or full |
crucible_bvt |
yes | Run a YAML Build Verification Test flow on a device: scripted UI steps with screenshot, element and assert checks |
crucible_score |
no | Latest quality score or history. 7-dimension weighted composite, ship gate 85 |
crucible_preflight |
no | App-store readiness: code quality, store listing (<project>/store/store-listing.md), visual assets, brand brief (<project>/store/brand-brief.json), build artifacts. Threshold 80% |
crucible_baseline |
yes for capture; no for latest, list, prune |
Golden baselines for screenshot and a11y-tree regression detection (crucible baseline capture|list|prune on the CLI; a11y mode, latest and promote are MCP-only) |
crucible_extract_assets |
no | Ranked marketing screenshots (with social/store variants) from a video recording |
crucible_usage_stats |
no | Usage analytics over the local MCP call log: per-tool calls, success rate, p50/p95 latency, dormant tools |
| Environment variable | Default | Purpose |
|---|---|---|
LINC_HOME |
~/.linc |
Root of crucible state ($LINC_HOME/crucible/*) and the MCP call log |
LINC_PROJECTS_DIR |
~/projects |
Directory scanned for */linc-manifest.yaml project discovery |
LINC_WORKSPACE |
$LINC_HOME/projects/LINC (or ~/projects/LINC when LINC_HOME is unset) |
Optional LINC workspace fallback read by preflight when <project>/store/ is absent |
LINC_DEVICE_FLEET_FILE |
$LINC_HOME/device-fleet.yaml |
Personal-device registry; devices marked personal: true are refused unless allow_personal=True. A missing file means no personal devices are registered; a file that exists but cannot be read or parsed refuses EVERY device operation unless allow_personal=True |
LINC_LOG_LEVEL |
WARNING |
Level of the structured logs written to stderr |
LINC_BUILD_GATE |
(unset) | 1 wraps builds in linc-build-gate (host build-slot mutex) |
LINC_CRUCIBLE_BUILD_TIMEOUT |
600 |
Build timeout in seconds for pour; invalid values fall back to the default |
FORGE_PROJECTS_FILE |
$LINC_HOME/.forge/projects.yaml |
Legacy project registry, read-only; manifests discovered on disk take precedence |
FORGE_RUN_ID |
manual |
Deprecated session id, read when CRUCIBLE_RUN_ID is unset or empty |
CRUCIBLE_USAGE_LOG |
redacted |
Local usage log mode: off, redacted or full; an unknown value means redacted (see Trust) |
CRUCIBLE_RUN_ID |
FORGE_RUN_ID, else manual |
Session id stamped on usage log entries; a value outside [A-Za-z0-9._-]{1,128} is logged as manual |
CRUCIBLE_SCORES_DIR |
$LINC_HOME/crucible/scores |
Quality score history |
CRUCIBLE_BASELINES_DIR |
$LINC_HOME/crucible/baselines |
Visual and a11y-tree baselines |
CRUCIBLE_FLEET_FILE |
$LINC_HOME/crucible/fleet.yaml |
Device fleet registry |
CRUCIBLE_IOS_TEAM_ID |
(unset) | Apple Developer Team ID for physical-iOS device builds |
CRUCIBLE_MOBSF_API_KEY |
(unset) | API key for the optional MobSF security scan (assay mode full) |
ANDROID_HOME |
(unset) | Android SDK root; its build-tools/*/aapt2 reads the package name from a built APK |
ANDROID_SDK_ROOT |
(unset) | Legacy Android SDK root, used when ANDROID_HOME is unset |
Generated from linc_crucible.config_contract.ENV_VARS (crucible fuelcheck --env-table); pinned to the variables read under src/ by tests/test_config_contract.py.
State crucible writes between runs:
| Path | Contents |
|---|---|
$LINC_HOME/crucible/scores/<project>/ |
Quality score history (JSON) |
$LINC_HOME/crucible/baselines/<project>/ |
Visual and a11y-tree baselines |
$LINC_HOME/crucible/assets/ |
Extracted screenshots |
$LINC_HOME/crucible/fleet.yaml |
Device fleet registry |
$LINC_HOME/mcp-calls.jsonl |
Local MCP usage log; redacted by default (CRUCIBLE_USAGE_LOG): keeps tool name, timing, success and allowlisted parameter values, replaces every other parameter value with <redacted len=N> and absolute paths in error with <path> (dir 0700, file 0600) |
| Problem | Cause | Fix |
|---|---|---|
| "Project not found" | No linc-manifest.yaml under $LINC_PROJECTS_DIR |
Add the manifest to the project root, or point LINC_PROJECTS_DIR at its parent |
"Nerve unreachable at http://localhost:8111 — start it with nerve daemon" |
nerve daemon not running | nerve daemon, then curl http://localhost:8111/health |
| "No APK found" | Build not run | crucible pour --project myapp; check build_command in .forge.yaml |
| "No quality score found" | No assay yet | crucible assay --project myapp |
| Build timeout (>10 min) | Cold build cache | Build once by hand, then pour with skip_build |
- Your project's commands run as you.
assayandpourrun thebuild_command,test_commandandlint_commandfrom the project's.forge.yamlwith your user's permissions. crucible never spawns a shell itself (argvlist,shell=False,shlex.split) and refuses|,;,&,$(and backticks, but a project command that is itself a shell (sh -c '…',bash script.sh) runs a shell because the project asked for it: the trust boundary is the project. Only point crucible at projects you trust. Details: SECURITY.md. - No telemetry, no phone-home. Nothing leaves the machine: crucible talks to your local nerve
daemon, and to MobSF only if you run a
fullassay against one you started. The nerve daemon on loopback is a trusted peer: crucible acts on the device list, element trees and file paths (e.g. the screenshot path) nerve returns. - A local usage log, under your control. Each MCP tool call is appended to
$LINC_HOME/mcp-calls.jsonl(default~/.linc/mcp-calls.jsonl) with the tool name, timing and success. SetCRUCIBLE_USAGE_LOG=off|redacted|full:redacted(default) — keeps allowlisted parameter values (such asproject,mode,actionand boolean/numeric flags), replaces every other parameter value length-only (<redacted len=N>) and scrubs redacted values, host roots and absolute paths (<path>) fromerror; new state directories are created0700and the log file is created owner-only (0600).off— nothing is written.full— raw parameter values. Opt in only on a machine you trust.
This is a release mirror of LINC's private repository. The code is built by LINC's AI software factory under human review; each release lands as one commit. Issues are welcome; pull requests by arrangement — see CONTRIBUTING.md.
Built by LINC Innovations — we diagnose flaky device test suites.
Licensed under the Apache License 2.0.